Instruction file imported from shd101wyy/Yo (
.github/instructions/yo-syntax.instructions.md). Copyright stays with the author.
Yo Language Syntax Rules
Curly braces {...} behave differently based on separators
- A brace group is a record unless it contains a
;, and that rule is the same in EVERY position — a value, the left of::/:=/=, and amatchpayload pattern (decided 2026-09-16,plans/archive/LLM_FRIENDLY_TOOLCHAIN_AND_SYNTAX.md§4, where the.{ … }and comma-decides alternatives are recorded as declined). Patterns and literals therefore never disagree about what a brace means. { expr }without semicolons creates an anonymous struct value, NOT a block!{ x }— a bare identifier — is a one-field record (_(x : x)), the case that looks most like a block in other languages. The parser's clear error covers a single non-fieldable expression (a call, amatch), but{ x }IS fieldable, so it is accepted; the compiler reports it at the literal when the expected type can never be a record, and when there is no expected type (y := { x }) you simply get the record.{ expr; }with semicolons creates a begin block (sequence of statements)- Struct literal fields use spaces around
:and infix field values must stay grouped:{ x : (1 + 2), y : 3 }, not{ x: 1 + 2, y: 3 }. - If you want a single expression, write
exprdirectly. Don't wrap it in{...}unless you need a struct. - The parser now detects this mistake and emits a clear error: if
{ }contains a single non-struct expression (a function call,match,cond,while, etc.), it fails with:{ ... } without semicolons is parsed as a struct literal, not a block.
// WRONG - creates a struct:
result := { .Ok(()) }
// CORRECT - just the expression:
result := .Ok(())
// CORRECT - begin block with statements:
result := { x := 1; y := 2; .Ok(()) }
// WRONG - invalid anonymous struct value:
print_bool :: (fn(value: bool) -> i32)({
cond(
value => i32(1),
true => i32(0)
)
});
// CORRECT - just the expression:
print_bool :: (fn(value: bool) -> i32)(
cond(
value => i32(1),
true => i32(0)
)
);
// WRONG - lambda body wraps single expression in {...}, creating a struct:
io.async((io : Io) => {
cond(
done => .Ok(()),
true => .Err(e)
)
})
// CORRECT - lambda body is just the expression, no {...}:
io.async((io : Io) =>
cond(
done => .Ok(()),
true => .Err(e)
)
)
Always write cond(...) and match(...) with parentheses
cond(...)- NOTcond ...match(...)- NOTmatch ...- The parentheses are required and must not be omitted.
- Always write
cond(condition => result, true => default)
Paren hygiene: what yo fmt canonicalizes vs. keeps
yo fmt elides provably-redundant parentheses and keeps every
load-bearing group (plans/archive/FMT_PAREN_ELISION.md, 2026-09-02 — the
re-parse AST-equality gate makes a meaning-changing format structurally
impossible). Write the bare forms directly; what survives a yo fmt
pass is the canonical set.
Fmt elides these — write the bare form:
- Prefix calls:
-x(not-(x)),!flag(not!(flag)),?*T(not?(*(T))),**T,-p.a,-f(x). - Atom-like operands of any operator:
x + y(not(x) + (y)),y := -x(noty := (-x)). - Left same-operator chains:
a + b + c(not(a + b) + c) — same-op chains left-associate. - Whole call arguments:
f(a + b)(notf((a + b))), and the classic comma-delimiter rule:if(x == y, { ... }),assert(a == b, "m"),while(i < n, { ... })— never wrap a comma-delimited argument again. - Lambda parameters:
err -> { ... }(not(err) -> { ... }).
Fmt KEEPS these — the grammar needs them; do not remove by hand:
- An operator's infix-chain RHS:
y := (1 + 2),true => (x / y),{ x : (1 + 2), y : 3 }— the chain must be one group. - Mixed-operator groups:
(a + b) * c— no precedence; required. - Right operands:
3 + (4 - 5)AND3 + (4 + 5)— a right group changes the tree even for the same operator. - Prefix calls with infix arguments:
-(1 + 2)— a prefix operator binds exactly ONE postfix expression (plans/reference/PREFIX_OPERATOR_OPERAND_RULE.md Rule 1); the call parens are the operand boundary. - Multi-arg operator calls (
-(a, b)) and operator atoms ((!)).
if is sugar for cond
if(...) calls are desugared to cond(...) at parse time (desugar_if_calls in src/expr.yo), so every pass after parsing sees a real cond node. The prelude macro that used to back this was DELETED 2026-08-30 once the v0.2.20 seed shipped the desugar (plans/reference/MACRO_POLICY.md Part 3.2) — an if call the desugar leaves alone (odd arity, mismatched labels, a dynamically built AST) is now an error:
if(condition, then_body) // → cond(condition => then_body, true => ())
if(condition, then_body, else) // → cond(condition => then_body, true => else)
Use if for simple two-branch conditionals — especially for comptime early-return guards:
if(arch == Arch.Wasm32, {
printf(" skipped on wasm32\n");
return();
});
Use cond when there are more than two branches or when the branches are large.
Function definitions
(fn(param1 : Type1, param2 : Type2) -> ReturnType)({ body; return(expr); })- No space between
(fn() -> ReturnType)and({ body; }) - Function type body creation is a normal call:
(fn(...) -> T)({ body }), not(fn(...) -> T) { body } - Top-level aliases for function types also need parentheses:
Callback :: (fn(x : i32) -> i32);, notCallback :: fn(x : i32) -> i32; - Method definitions in
implusename : (fn(self : Self) -> ReturnType)({ body }) - Use
Selfinstead of the type name in method signatures, enum definitions, and struct definitions — the type name is not available inside its own definition - Use
struct(...)for record/effect-record types. The oldmodule(...),Module, andSelfModulesyntax has been removed; imported source files are namespace structs, and recursive type references use normalSelf. - Bare
Moduleis not a type-hierarchy alias anymore. UseTypefor compile-time type parameters/returns, and reflect source-module namespaces as ordinaryTypeInfo.Struct(...)values.
Anonymous function (=>) parameters cannot have type annotations
The => arrow form is for anonymous functions whose parameter types are inferred from the expected Fn(...) signature at the call site. You cannot annotate => parameters with : Type — parameter types come from the expected Fn signature.
// CORRECT — types inferred from expected Fn signature:
filtered := iter.filter((x) => (x.* > i32(2)));
// CORRECT — single parameter, parens optional:
filtered := iter.filter(x => (x.* > i32(2)));
// WRONG — `=>` parameters cannot have type annotations:
filtered := iter.filter((x : *(i32)) => (x.* > i32(2)));
If you need to specify parameter types explicitly, use the full fn(...) form or Impl(Fn(...))(...):
// Use fn(...) form when types must be explicit:
pred :: (fn(x : *(i32)) -> bool)(x.* > i32(2));
filtered := iter.filter(pred);
// Or inline:
filtered := iter.filter((fn(x : *(i32)) -> bool)(x.* > i32(2)));
Return value rules
- The last expression in
{ ... }without semicolon is the return value of the struct or enum constructor. - With semicolon, like
{ expr; }, the return value isunit.
Enum definition syntax
Enum variants are defined without the . prefix. The . prefix is only used when constructing or pattern matching enum values.
Use Self to refer to the enum type itself inside the enum(...) definition — the type name is not yet available during the definition. This applies to recursive types using Box(Self), ArrayList(Self), etc.:
// CORRECT — use Self for recursive references:
Expr :: enum(
Atom(id : ExprId, token : Token),
FnCall(id : ExprId, func : Box(Self), args : ArrayList(Self), token : Token)
);
// WRONG — type name not available inside its own definition:
Expr :: enum(
Atom(id : ExprId, token : Token),
FnCall(id : ExprId, func : Box(Expr), args : ArrayList(Expr), token : Token)
);
// CORRECT — no dots in definition:
Color :: enum(Red, Green, Blue);
Option :: (fn(comptime(T) : Type) -> comptime(Type))(
enum(None, Some(value : T))
);
// WRONG — dots in definition:
Color :: enum(.Red, .Green, .Blue);
// Dots are used when constructing values:
(c : Color) = .Red;
(x : Option(i32)) = .Some(i32(42));
// Dots are used in match branches:
match(c,
.Red => println(`red`),
.Green => println(`green`),
.Blue => println(`blue`)
);
If a match/cond branch returns an enum variant and the evaluator reports
"Failed to infer enum variant type", qualify the variant explicitly, e.g.
TypeValue.Unit instead of .Unit.
Do not write sibling enum-payload literal patterns such as .Some(false) and
.Some(true). Match the variant once (.Some(value)) and branch on value
inside the arm. The self-hosted codegen can otherwise emit duplicate C case
labels for the same enum variant.
When writing large enum matches, avoid binding a pattern variable with the same
name as a variant field (for example, prefer struct_field_types over
field_types). Some self-hosted codegen paths can currently emit invalid C for
those shadowing-shaped bindings.
All function and keyword calls require immediate (...)
- Write
func(arg1, arg2), notfunc arg1, arg2. - Do not insert whitespace before call parentheses:
func(arg), notfunc (arg). - Control-flow keywords follow the same rule:
return(value),return(),unwind(value),unwind(). - In
(exn : Exception) = Exception(throw: ((err) -> { ... }))handlers, addunwind(...)/unwind()when the handler does not resume normally. Calls likeexit(int(1))returnunit; they do not satisfy the handler'sResumeTypeby themselves. (unwindrequires the handler's lambda to be typed asctl(...) -> R, which it is when bound to actl-typed field likeException.throw.) - Prefix operators may use the call form (
&(x),!(ready)) or bind one bare postfix expression (&x,!ready,-value— plans/reference/PREFIX_OPERATOR_OPERAND_RULE.md Rule 1; see "Unary (prefix) operators" below, including the src/std seed constraint). A no-whitespace(after the operator is always the call form. - Macro unquote syntax is also tight: use
#(expr)and...#(exprs). - The operator token set is CLOSED (plans/reference/OPERATOR_SET_AND_PRECEDENCE.md): a run of operator characters is split greedily against the fixed table in
src/lexer.yo(_is_two_char_operator/_is_one_char_operator); an unknown run is a lex error, and**xlexes as*,*,x. Reserved operators (= := :: : => -> <: ?= && || # ...#, ranges) can never be bound or overloaded (is_reserved_operator_nameinsrc/token.yo, gated inevaluator/exprs/binding.yo). Adding a new operator = editing the lexer table deliberately, like a keyword. - DEFINING a macro (a
quote(...)parameter orunquote(...)return type) requirespragma(Pragma.AllowMacroDef);at the top of the file (plans/reference/MACRO_POLICY.md). Calling macros and working with quotedExprvalues (the derive-rule mechanism) is ungated. std is exempt this generation (seed-bootstrap constraint — seeis_macro_def_capable_fileinsrc/evaluator/memory_safety.yo). The stdtrymacro was REMOVED — match on theResult, or define a local equivalent under the pragma. - Dynamic field access with unquote requires grouping after the dot:
value.(#(field_expr)), notvalue.#(field_expr).
Note how the prefix rule disambiguates &x, y: a bare & binds ONE
postfix expression, so call(&x, y) passes a pointer to x plus y.
Taking the address of a tuple needs the call form:
// Pointer to x, plus y (bare prefix binds one postfix expression):
call(&x, y) // same as call(&(x), y)
// Address of the tuple (x, y) — call form required:
call(&(x, y))
Parens are also required for zero-argument control flow:
if((arch == Arch.Wasm32), {
return();
});
No operator precedence
Yo has no operator precedence. Two rules:
- A chain of the SAME operator is left-associative — no parentheses needed.
a + b + cparses as(a + b) + c;(A | B | C | D)is fine as-is. - Adjacent DIFFERENT operators require explicit parentheses — otherwise a
parse error: "Adjacent different operators need parentheses to clarify
grouping."
This includes
:=/=and a struct-literal field's:next to a binary operator:x := a + b;is E0003 — writex := (a + b);,ok := (p && q);,end : (j + usize(1)).
// CORRECT — same operator, no nesting needed:
(A | B | C | D)
1 + 2 + 3 // ⇒ (1 + 2) + 3
// WRONG — different operators with no parentheses:
a + b * c
// CORRECT — choose the grouping explicitly:
(a + b) * c // or: a + (b * c)
Source layout no longer affects grouping. There is NO newline-based
associativity (an earlier rule let a leading/trailing newline pick
associativity; it has been removed — see plans/archive/OPERATOR_ASSOCIATIVITY.md).
:, :=, =, ::, and -> are ordinary operators with no precedence, so a
type/value containing a different top-level operator must be parenthesized:
// `:` vs `->` — wrap the fn type:
next : (fn(inout(self) : Self) -> Option(Self.Item))
// `::` vs `->` — wrap a fn-type alias:
FuncType :: (fn() -> void)
// `:` vs `=` — wrap the typed binding:
(err1 : AnyError) = dyn(ErrA(`error A`));
// `:=` vs `&&` — wrap the operator RHS:
is_neg := ((a == "-") && (b == 1));
Formatter-specific syntax preservation:
- Canonical pointer dereference is
ptr.*; format legacyptr.(*)asptr.*. - Keep compact collection and tuple literals compact when they are single-line, even inside a multiline call:
[1, 2, 3],(1, 2, 3).
Special tight syntaxes must stay immediate: macro splices #(expr), Option sugar ?T / nullable pointers ?*T, and negated trait constraints T <: !Runtime must not be formatted as # (expr), ? T, or T <: ! Runtime.
Example: ((value <= 0x10FFFF) && ((value < 0xD800) || (value > 0xDFFF)))
Unary (prefix) operators bind exactly ONE postfix expression
Since 2026-08-21 (plans/reference/PREFIX_OPERATOR_OPERAND_RULE.md Rule 1), a bare
prefix operator (- ! ~ & * ? ^) followed by a primary is
valid: it binds exactly one postfix expression — the primary plus its
dot-chains and calls — and nothing more.
// Valid, and preferred in NEW user code:
x := -1;
assert(!d.is_empty(), "bare prefix binds the whole call chain");
p := &x;
t :: ?*u8; // = ?(*(u8)) — Option of raw pointer
y := 3 - -3; // infix minus, then prefix minus
// An INFIX operand still needs parens (one postfix expression only):
-(1 + 2) // NOT -1 + 2, which is (-1) + 2
The formatter emits bare prefix forms tight (-1, !x, ?*i32),
keeps - -1 spaced (a tight --1 reads as a C decrement), and never
tightens a pair that would re-lex as one token (& &x stays spaced —
&& is a token). The historical seed constraint (parenthesized
spellings until a seed carried the rule) was lifted 2026-09-02 —
v0.2.21 ships it — and the 2026-09-02 tree sweep converted src/,
std/, and tests/ to the bare spellings.
!x && y groups as (!x) && y — the prefix operator binds only the
one postfix expression. Since unary and infix are different operators
with no precedence, write the other intent with parens:
// (NOT x) AND y:
!x && y
// NOT (x AND y):
!(x && y)
Special note for reference-semantics types (ref(struct(...)) / ref(enum(...))): passing by value already propagates mutations (RC fields are shared), so *(MyRefType) pointers are rarely needed. Prefer passing by value and avoid &obj in most cases.
Parameter form by type kind
The right shape for a function parameter depends on what kind of type the value is:
| Type kind | Shape | Why |
|---|---|---|
ref(struct(...)) / ref(enum(...)) (incl. atomic(ref(...))) |
name : Type |
Reference-semantics types — mutations propagate via the underlying RC value. No pointer needed. |
struct(...) value type (read-only) |
name : Type |
Pass by value. Cheap if small; consider inout for large structs. |
struct(...) value type (need mutation) |
inout(name) : Type |
Caller's binding sees in-place writes. See inout section below. |
enum(...) (read-only) |
name : Type |
Same as struct. |
enum(...) (need mutation) |
inout(name) : Type |
Same as struct. |
Primitive (i32, bool, …) |
name : Type for read, inout(name) : Type for mutation |
Same rule. |
Receiver of mutating method on ref(struct(...))/ref(enum(...)) |
self : Self |
Reference semantics — explicit inout(self) is unnecessary noise (though it works). |
| Receiver of mutating method on value type (trait or inherent) | inout(self) : Self |
Caller-side writes propagate. Established for Hash, Clone, ToString, Iterator. |
Raw FFI pointer (legitimate *(T)) |
name : *(T) |
Only when interfacing with C / the runtime ABI. Requires pragma(Pragma.AllowUnsafe); at the file top. |
Anti-patterns to avoid:
// ✗ Pointer on a reference-semantics type — wraps a reference in another reference
foo : (fn(ctx : *(EvalContext)) -> unit)({ ctx.*.method() })
// ✗ Inout on a reference-semantics type — redundant; reference semantics already share state
foo : (fn(inout(ctx) : EvalContext) -> unit)({ ctx.method() })
// ✓ Plain — concise and correct
foo : (fn(ctx : EvalContext) -> unit)({ ctx.method() })
The same applies at call sites: don't wrap reference-semantics arguments with &(obj) to pass to a function expecting one; just pass obj.
When choosing between inout(self) : Self and self : Self for a method receiver:
- If the receiver type is fundamentally a value type (anything other than
ref(struct(...))/ref(enum(...))), useinout(self) : Selffor mutators. - If the receiver type is a reference-semantics type (
ref(struct(...))/ref(enum(...))), plainself : Selfis the idiom — the methods documented insrc/env.yo,src/emitter.yo, etc. follow this. - Trait declarations should match the dominant case of their impl targets. Existing widely-implemented traits (
Hash,Clone,ToString,Iterator,Index) useinout(self) : Selffor the reasons above; new traits that are reference-semantics-specific can use plainself : Self.
Recursion requires recur
Yo does not allow a function to call itself by name. Use the recur keyword instead:
// WRONG — "Variable 'factorial' not found":
factorial :: (fn(n : i32) -> i32)(
cond(
(n <= i32(1)) => i32(1),
true => (n * factorial((n - i32(1))))
)
);
// CORRECT — use recur:
factorial :: (fn(n : i32) -> i32)(
cond(
(n <= i32(1)) => i32(1),
true => (n * recur((n - i32(1))))
)
);
For methods, pass self explicitly as the first argument:
impl(Tree,
depth : (fn(self : Self) -> i32)(
cond(
self.is_leaf() => i32(0),
true => (i32(1) + recur(self.left()))
)
)
)
recur works in any fn body (free functions and methods). The arguments must match the function's parameter types.
Async recursion — recur does NOT work inside io.async
recur refers to the nearest enclosing fn. Inside io.async((io : Io) => ...), that lambda is the enclosing fn, so recur would call the lambda — not the outer function. This causes an argument-type mismatch error.
Pattern for async recursion: Replace recursion with an iterative worklist:
// WRONG — "Variable 'walk_dir' not found" inside io.async:
walk_dir :: (fn(path: Path, io: Io) -> Impl(Future(unit, Io)))(
io.async((io : Io) => {
entries := io.await(read_dir(path, io), io);
// CANNOT call walk_dir recursively here
})
);
// CORRECT — bundle the needed effects into one struct and iterate with a stack:
WalkCtx :: struct(io : Io, exn : Exception);
walk_dir :: (fn(root: Path, ctx : WalkCtx) -> Impl(Future(unit, WalkCtx)))(
io.async((ctx : WalkCtx) => {
stack := ArrayList(Path).new();
{ stack.push(root); };
while(stack.len() > usize(0), {
cur := match(stack.pop(), .Some(p) => p, .None => return());
entries := ctx.io.await(read_dir(cur, ctx.io), ctx.io);
// process entries, push subdirs to stack…
});
})
);
Self in generic type constructors
Self works inside generic type constructor functions too — it refers to the current type instantiation (e.g., Tree(T) inside Tree):
// CORRECT — Self refers to Tree(T):
Tree :: (fn(comptime(T) : Type) -> comptime(Type))(
enum(
Leaf(value : T),
Node(left : Box(Self), right : Box(Self))
)
);
// WRONG — Tree is not available inside its own body:
Tree :: (fn(comptime(T) : Type) -> comptime(Type))(
enum(
Leaf(value : T),
Node(left : Box(Tree(T)), right : Box(Tree(T)))
)
);
Use recur(args) only when calling the type constructor with different type arguments than the current instantiation (e.g., recur(i32) inside Tree(T) to get Tree(i32)).
Module imports
Use destructured imports for files in the same directory:
// CORRECT — destructured import with relative path:
{ RegexNode, CharRange, GroupNameEntry } :: import("./node.yo");
// CORRECT - Named module
node_module :: import("./node.yo");
// CORRECT — named import for std library modules:
{ ArrayList } :: import("std/collections/array_list");
{ String } :: import("std/string");
// CORRECT — glob destructure when you really want every export in scope
// (there is no `open(...)` builtin: it was removed 2026-09-10):
{ ... } :: import("std/string");
// WRONG — `import "path" as name` does NOT work for .yo files:
// import "./node.yo" as node; // causes "Invalid function call on type: comptime_str"
// WRONG — absolute-style paths from within a subdirectory:
// import "std/regex/node" as node; // module resolution fails
For files within the same directory, always use relative paths (./file.yo). For std library modules, use the standard "std/module" path.
Do NOT import std/prelude — the prelude is automatically loaded for every file. Explicitly importing it (import "std/prelude" or import "std/prelude.yo") will produce a compile error. Third-party modules named prelude.yo are fine — only the std prelude is blocked.
GADT enum syntax
GADT constructors use -> recur(Type1, Type2, ...) after fields to specify the return type:
Value :: (fn(comptime(T) : Type) -> comptime(Type))(
enum(
IntVal(i : i32) -> recur(i32), // constructs Value(i32)
BoolVal(b : bool) -> recur(bool), // constructs Value(bool)
MGeneric(v : T) // no annotation = unconstrained
)
);
With discriminants, wrap the variant in parentheses:
Tagged :: (fn(comptime(T) : Type) -> comptime(Type))(
enum(
(TagInt(i : i32) -> recur(i32)) = 10,
(TagBool(b : bool) -> recur(bool)) = 20
)
);
short, long, int, char are not usable as variable names
They are builtin type names. short := io.await(...) fails with:
Error: Failed to define variable "short":
The diagnostic points at the binding and never mentions keywords, so it reads
like the RHS failed to type — the wrong place to look. Measured 2026-08-12:
short, long, int and char are rejected; float, double, signed,
unsigned, register and volatile are accepted. Rename the local.
Other syntax notes
unitis a type not value,()is the unit value.- There is no
loopfunction. Usewhile(true, body)for a runtime infinite loop. while(cond, body)is always a runtime loop, regardless of whethercondis compile-time known.- Do NOT wrap the
whilecondition inruntime(...)—while(runtime(cond), body)is redundant because the condition is already evaluated at runtime by default. Writewhile(cond, body). (runtime(...)only matters in a::/comptime context to force runtime evaluation; awhilecondition is never that context.) while(comptime(cond), body)explicitly opts into compile-time loop unrolling. Requirescondto be a compile-time-known value. The evaluator will error if it detects an infinite loop (e.g.,while(comptime(true), ...)with nobreak/return/unwind).- If you use a comptime-only (
::) variable in a barewhilecondition (withoutcomptime()), the compiler will error: the condition would never change at runtime, causing an infinite loop. assert/paniclive instd/assert({ assert, panic } :: import("std/assert");) — not prelude-ambient. Messages accept anyToStringtype (template strings OK);assert(cond)uses a default message. The diverging builtin for value-position arms is__yo_panic("str only").- Pointer comparison is plain
==/!=/</<=/>/>=(Eq/Ord impls on*(T), address identity). Pointer arithmetic is METHODS:p.add(n),p.sub(n)(offset byusizeelements),p.offset_from(q)(signed element distance →isize). Comparisons are safe; arithmetic methods requireunsafe(...). - Associated-type binding syntax works only on BARE trait names, not parameterized trait constructors.
where(Self <: Iterator(Item := A))is fine (Iteratoris a bare trait);where(T <: Add(T, Output := T))is REJECTED ("Argument count mismatch: expected 1, got 2") becauseAddis a trait CONSTRUCTOR (Add(Rhs)) and the binding parses as a second argument. Use the plain bound (where(T <: (Add(T), Default))) and let per-call specialization resolveOutput— measured working end-to-end (preludeIterator.sum). - A module-level
NAME :: <backtick String literal>is REJECTED ("Expected compile-time value for NAME"):::constants must be comptime values, and a backtick literal (likeString.from(...)) constructs a runtime RCString. Double-quotedstr(app_name :: "yo-demo";) and numeric constants (_COMMA :: u8(44);) are fine —stris static. For bigStringdata (e.g. an embedded table), put the backtick literal INSIDE the function that consumes it (data :=+ blob) — it is one C string literal there; a module-level:=global also works but runs at module init and module globals get unmangled C names (alias hazard). Precedent:std/encoding/html_entities.yo. - A leading UTF-8 BOM (U+FEFF) in a
.yofile is skipped by the lexer (byte 0 only; a U+FEFF elsewhere is an ordinary identifier rune). Row/column/characterare as if the file had no BOM;Token.byte_offsetstill counts the 3 BOM bytes. Files written by Windows PowerShell 5.1'sSet-Content -Encoding UTF8carry such a BOM (PS 7 does not add one), which is howscripts/install.ps1's verification step once failed withVariable "<BOM>open" not found(issues/fixed/lexer-rejects-leading-utf8-bom.md). When a script must write.yosource BOM-less on BOTH PowerShell generations, use[System.IO.File]::WriteAllText(UTF-8 without BOM by default).
unsafe(...) and pragma(Pragma.AllowUnsafe); for raw pointer operations
User code is memory-safe by default. To use raw pointers, a .yo file must declare pragma(Pragma.AllowUnsafe); at the top — this opts the entire file into unsafe-capability. Without the pragma, unsafe(...) itself is a compile error and pointer ops are forbidden.
pragma(Pragma.AllowUnsafe);
main :: (fn() -> unit)({
x := i32(42);
p := &(x);
v := unsafe(p.*); // OK
});
Safe code cannot even HOLD a raw pointer value (2026-09-07): an expression whose type is *(T) or carries one directly (Option(*(T)) from a pointer iterator's next()) is a compile error outside an unsafe-capable file (std, the trusted base, is exempt) — borrow elements with for(coll, inout(x) => …) instead. A file may also declare pragma(Pragma.StrictBorrow); to turn the borrowed loop's runtime invalidation panics into compile errors (calls the mutation summary cannot prove harmless are rejected).
Inside an unsafe-capable file, the following operations require an explicit unsafe(...) wrap (so the unsafe surface stays greppable):
- Pointer dereference:
p.*(read),p.* = v(write) - Pointer arithmetic:
.add(n),.sub(n),.offset_from(q) consume(p.* = v)(deref-and-init)
Operations that stay safe (no wrap needed): &(x) to take an address, passing/storing/returning pointers, pointer comparison (==, <, etc.), and pointer-type casts ((*u8)(p)).
unsafe(expr) is a regular builtin call taking exactly one argument — the same shape as return(...), consume(...). It's a compile-time marker only; at codegen it lowers to its inner expression.
pragma(...) is also a regular builtin call. The argument Pragma.AllowUnsafe is recognized at the AST level; you can place the pragma anywhere at the top of the file (after the file's leading // comments). Multiple pragma(...) declarations are allowed.
// Single expression:
v := unsafe(p.*);
// Assignment:
unsafe(p.* = i32(12));
// cond / match wrapped directly (no braces — `{...}` without `;` is a struct):
result := unsafe(cond(
(n > i32(0)) => p.*,
true => i32(0)
));
// Multi-statement begin-block (semicolons required):
n := unsafe({
p.* = i32(1);
(p.* + i32(2))
});
unsafe(...) does NOT propagate through function calls — each function body is evaluated with its own context. If a function's body does pointer ops, the body must wrap them locally; callers don't need unsafe(...) at the call site. See plans/reference/MEMORY_SAFETY.md; user-facing version: docs/en-US/MEMORY_SAFETY.md.
Extern "c" calls also require an unsafe(...) wrap
Even inside a pragma'd file, every extern "c" call site must be wrapped in unsafe(...). The pragma authorizes DECLARING the FFI symbol (via extern(...) / c_include(...)); the wrap is the per-call audit marker that lets yo unsafe-report line up with the actual UB-capable lines.
pragma(Pragma.AllowUnsafe);
{ memcpy, strlen } :: import("std/libc/string");
copy :: (fn(dst : *(u8), src : *(u8), n : usize) -> unit)({
_ := unsafe(memcpy((*void)(dst), (*void)(src), n)); // wrap required
});
len :: (fn(s : *(char)) -> usize)(unsafe(strlen(s))); // wrap required
asm(...) and extern(...)/c_include(...) declarations themselves do NOT need a wrap — the asm keyword and the declaration syntax are themselves the per-site markers, and the pragma is the file-level gate.
c_include(...) / extern(...) are module values
Both evaluate to a module value (a source-namespace struct, like import(...)), so their names enter scope only through a binding — c :: c_include(...) then c.fputs(...), { strlen : c_strlen } :: c_include(...) (select + rename), or the glob { ... } :: c_include(...). A bare statement c_include(...); / extern("Yo", …); is parse-time sugar for the glob, so the existing declaration style keeps working and is what std/ and src/ must keep using until the seed carries the feature. Destructuring runs the no-shadowing rule, so a declared name that is already in scope is an error whichever came first: qualify or rename. User docs: docs/en-US/FFI.md; design: plans/reference/C_INCLUDE_EXTERN_MODULE_VALUE.md.
*T(x) is NOT a pointer cast — write (*T)(x)
A bare prefix operator binds ONE postfix expression, and a call is one postfix
expression (same rule as -f(x) ⇒ -(f(x))). So *void(p) parses as
*(void(p)) — * applied to the VALUE void(p) — and the evaluator rejects it
(*(val) where val is not a type → error, src/evaluator/calls/pointer.yo). The
pointer-type constructor must get a TYPE as its operand, then the resulting
pointer type is called with the value:
free(.Some((*void)(buf))); // CANONICAL — grouped type, then the value
free(.Some(*(void)(buf))); // same AST, but yo fmt rewrites it to the above
(*void)(buf) and *(void)(buf) parse identically; the grouped form is the one
canonical spelling (DECIDED 2026-09-06, plans/reference/FMT_CALLEE_PREFIX_CANONICALIZATION.md):
yo fmt moves the parens for ANY prefix operator call in callee position —
*(u8)(s) → (*u8)(s), *(*(u8))(p) → (**u8)(p), -(x)(y) → (-x)(y) —
and never touches the broken bare spellings (*void(p) stays: fmt cannot invent
a cast). In type position the chain rule applies: **i32 = *(*(i32)) is the
pointer-to-pointer type, ?*u8 the optional pointer.
c_include-typed integers: cast to a Yo int before comparing
Values typed by a c_include type alias (ssize_t, off_t, …) can fail to
transpile in comparisons (n <= isize(0) emits // Failed to transpile in
condition position — a class yo check cannot see; the C compiler then
errors). Casts DO emit correctly, so bind through a cast at the call site:
// WRONG — may emit "// Failed to transpile n <= isize(0)":
n := unsafe(write(int(fd), (*void)(p), count)); // n : ssize_t
if(n <= isize(0), { ... });
// CORRECT — cast to a Yo integer at the binding:
n := i64(unsafe(write(int(fd), (*void)(p), count)));
if(n <= i64(0), { ... });
See issues/cinclude-int-comparison-fails-to-transpile.md.
auto-generated:// URIs (macros, derive expansions) bypass the per-call wrap — the macro author owns the contract via the expansion site. See plans/archive/EXTERN_UNSAFE_WRAP.md.
Raw views and the static-str model (post slice-rework)
The builtin Slice(T) and the view methods String.as_str() /
ArrayList.as_slice() are DELETED (plans/archive/SLICE_REWORK.md). The model:
stris the builtin view of STATIC string bytes (literals / template segments) — immortal backing, freely storable/returnable, no flow constraints.- Range indexing COPIES:
arr(a..b)→ newArrayList(T),s(a..b)onString→ newString;strranges stay zero-copy static windows. - There is no aliasing view type:
ListView(T)was deleted (noIndex, no iteration, no consumers — superseded by the copying range forms above). If a real view type is ever needed it comes back with iteration andIndex. - Privileged ptr+len plumbing uses
RawSlice(T)(prelude). Naming it — or any type whose representation carries a raw pointer — in a parameter annotation requirespragma(Pragma.AllowUnsafe);(a representation-based gate, not just the*(T)syntax gate). inout(name) : Tflowability (rules R1–R4) is unchanged. Seedocs/en-US/FLOWABILITY.mdandtests/flowability_comprehensive.test.yo.
Return-slot modifiers: inout is BANNED; comptime goes on the label
Functions cannot return inout. It is second-class and exists in parameter position and as a LOCAL BINDING (plans/archive/INOUT_LOCAL_BINDINGS_AUDIT.md): inout(y) := x; names x's slot for the rest of the block (y = v writes x; x = v is seen through y; copy := y copies the pointee). Accepted places: a whole variable of any scope, a field path rooted at a value struct, or a field path through a reference-semantics value (h.n, a.b.n) — that innermost object is then PINNED for the binding's scope (a hidden owning local; released on break/return/unwind). Rejected: element places (xs(i), p.* — borrow elements with the for macro), rvalues, Type.member, compile-time roots, ::, module-level declarations, io.async bodies, and MOVING the root while a binding is live (sink(own(x))). Return the value instead of an inout (reference-semantics values are handles that mutate in place; struct values copy), or take a callback parameter that receives inout(name) : T. An inout ARGUMENT is a simple lvalue place: a variable, or var.field rooted at a local/param — chains through an intermediate reference-semantics value and module-level field roots are rejected for ARGUMENTS (bind the value to a local first: b := a.b, or use a local inout binding, which pins).
| Form | Verdict |
|---|---|
-> comptime(T) (unlabeled), -> (comptime(name) : T) (labeled) |
✅ valid |
-> inout(T), -> (inout(name) : T), -> (name : inout(T)) |
❌ rejected — functions cannot return inout |
-> (name : comptime(T)) |
❌ rejected — modifier goes on the label |
Enforced at function-type evaluation (src/evaluator/types/function.yo). See tests/ref_return_ban.test.yo.
Signed-integer overflow is defined (wrap-around)
Yo passes -fwrapv to clang/gcc/zig by default, so signed-integer overflow is two's-complement wrap-around, not UB. x := i32(2147483647); y := (x + i32(1)); evaluates to i32(-2147483648), not silent miscompilation. Opt-out: --cflags='-fno-wrapv'.
COMPTIME arithmetic is the opposite: it REJECTS overflow. The wrap-around above is a property of the runtime operator. Whenever both operands are compile-time constants the Comptime* overload is selected instead (__yo_comptime_i32_add and friends in std/prelude.yo), and that one raises a hard error rather than wrapping:
y := (i32(2147483647) + i32(1)); // ERROR: Integer overflow in compile-time evaluation
// 2147483647 + 1 = 2147483648
// Result 2147483648 exceeds i32 range [-2147483648, 2147483647]
x := i32(2147483647);
y := (x + i32(1)); // OK — runtime add, wraps to i32(-2147483648)
The two forms look nearly identical, so this bites when writing a test that asserts wrap-around: the expected value must also be built from a runtime binding, e.g. (seed : i32) = i32(2147483647); (expected : i32) = (seed + i32(1));. Writing the expectation as a folded constant fails the compile instead of the assertion. (Measured 2026-08-25 while adding the atomic fetch_* family — tests/sync/atomic.test.yo "wraps like the runtime operator".)
// SAFETY: comment convention
Every non-obvious unsafe(...) site in stdlib should have a // SAFETY: comment explaining the contract (what invariant guarantees the deref/arith is in bounds and the pointer is live). yo unsafe-report scans the previous ~8 lines preceding each unsafe site and surfaces the comment in the report.
match(
self._ptr,
// SAFETY: idx bounds-checked above (idx < self._length);
// _ptr points at the Rc-managed heap buffer.
.Some(_ptr) => (_ptr.add(idx)),
.None => __yo_panic("ArrayList: empty")
)
inout(name) : T parameters for in-place mutation
For mutating a caller's variable without raw pointers, use the inout parameter modifier. It wraps the parameter name (parallel to own(name)) and gives second-class reference semantics — reads/writes through the parameter access the caller's storage.
swap :: (fn(inout(a) : i32, inout(b) : i32) -> unit)({
tmp := a;
a = b;
b = tmp;
});
main :: (fn() -> unit)({
x := i32(1);
y := i32(2);
swap(x, y); // no `&()` syntax at the call site
assert((x == i32(2)), "swapped");
});
Rules:
inout(...)cannot combine withown(...)(opposite calling conventions) or withgeneric/usingparameters (those are erased at runtime — no callee-side binding to mutate).inoutCAN combine withcomptimeascomptime(inout(name)) : T(outer comptime, inner inout). The parameter is erased at runtime and mutations propagate via the evaluator's compile-time binding update path. The preludeComptimeIndextrait uses this form (index : (fn(comptime(inout(self)) : Self, comptime(idx) : Idx) -> comptime(*(Self.Output)))) to let comptime index methods mutate the caller's value without a raw pointer parameter.- Inside the callee, the inout-param identifier behaves like a regular variable for reads (
tmp := a;) and assignments (a = b;). - Calls through inout-params chain naturally:
fn outer(inout(x))callingfn inner(inout(p))withinner(x)passes&xtoinner(the caller-side&is implicit). - At codegen,
inout(name) : Tlowers toT*in C. Reads ofnamein the callee become(*name); writes become(*name) = v. For interior-ref arguments (xs(i),self->_inner(i)), the codegen emits__yo_borrow_acquire/releasebracketing the call (a same-cache-line counter increment/decrement on the container's RC header — ~0% overhead). Container growth operations (realloc/free inside a reference-semantics method) auto-assert the counter is zero, turning the one statically-unprovable interior-ref shape into a deterministic panic.comptime(inout(name))has zero codegen impact (the parameter is erased).
inout is the safe in-place-mutation primitive for user code. Stdlib trait methods that previously took (self : *(Self)) have all been migrated to (inout(self) : Self) — Hash, Clone, ToString, Index, ComptimeIndex, Writer, Reader, and Iterator (the for-loop redesign documented in plans/archive/ITERATOR_REDESIGN.md shipped alongside Phase D of plans/reference/MEMORY_SAFETY.md).
Public stdlib boundary — no raw pointer leaks
Every public top-level fn(...) in std/ should take and return value or inout-bound types. Raw *(T) in a public signature is allowed only when (a) the function lives in an FFI directory (libc/, linux/, darwin/, cuda/, sys/, sync/), or (b) the function name signals raw-pointer use by contract (*_cstr, *_ptr, from_raw_parts, as_ptr, names starting with raw_). Anything else is a leak — migrate to owned collections (ArrayList(u8)/String) for buffers, inout(name) : T for in-place mutation, or a higher-level safe type (RawSlice(T) for pragma'd internals).
Verify with yo public-safe-report ./std (or ./src). It scans every top-level public fn(...) declaration, skips extern(...) blocks and the directories/name patterns above, and reports any remaining raw-pointer leak. Source: src/public_safe_report.yo. Currently reports 0 findings; keep it that way when adding new stdlib surface.
for loop macro — correct form
The for macro is a 2-argument prelude macro. The value form iterates BY VALUE (it expands to coll.into_iter()); the BORROWED form for(coll, inout(x) => body) / for(map, (k, inout(v)) => body) binds each element as an inout local into the collection's storage (pointer iterator iter() under the hood):
for(list, (x) => { process(x); }); // value form: macro expands to list.into_iter()
for(names, (s) => { s.push_str("!"); }); // reference-semantics elements are HANDLES: mutates in place
for(chain.map(f), (y) => println(y)); // combinator chain: pass as the value-form iterator
- First argument: the collection itself, or an iterator chain (
.map().filter()-style). - Second argument: an anonymous closure
(x) => body;xisTby value (a handle for reference-semantics element types — mutating it mutates the element in place). - The borrowed form
for(coll, inout(x) => body)(plans/archive/INOUT_LOCAL_BINDINGS_AUDIT.md §7): the collection is bound to a hidden local (pinned) and its RUNTIME borrow flag is held for the whole loop;xis aninoutlocal into the element's storage — struct fields write in place, RC elements are not dup'd,bump(x)passes the same pointer.break/continue/return/unwindrelease the flag. Growing, shrinking or removing from the collection inside the body — through the same variable or ANY alias — PANICS (container operation while an interior reference … borrows from it); collect changes and apply them after the loop — the compiler emits that assert at the entry of every RC-object method whose body may mutate the object, so third-party collections need no annotation. Maps: usefor(map, (k, inout(v)) => body)(key by value, value borrowed); plaininout(e)yields the whole entry. Works on every collection with a pointeriter()(ArrayList, Deque, LinkedList, PriorityQueue, HashMap, HashSet, OrderedMap, BTreeMap);Array(T, N)and combinator chains take the value form. Not available inside anio.asyncbody that suspends (v1; same rule asinoutlocal bindings). The old spellingref(x) =>is gone. - Do NOT use
for(x, arr, { body })— this older 3-arg form is an evaluator-internal representation and is not valid top-level Yo source. (The self-hosted evaluator's internal for-loop handler currently only understands the 3-arg form; this is tracked inissues/fixed/eval-for-loop-3arg-vs-2arg.md.)
Function call syntax — required immediate (
In Yo, function calls must always use immediate parentheses:
func(a, b)— normal call with two argumentsfunc (a, b)— invalid whitespace before(func a, b, c— invalid paren-less call- Prefix operators follow the same rule:
&(x),!(ready),-(value) - Control flow follows the same rule:
return(value),return(),unwind(value),unwind()
Always use func(a, b) with no space. Never func (a, b) or func a, b.
Partial application with _ placeholder
Use _ as a placeholder argument to partially apply any comptime function:
// Type constructors (return comptime(Type)):
IntResult :: Result(_, i32); // fn(comptime(T) : Type) -> comptime(Type)
(r : IntResult(bool)) = .Ok(true); // = Result(bool, i32)
// Comptime value functions:
add :: (fn(comptime(x) : i32, comptime(y) : i32) -> comptime(i32))((x + y));
add1 :: add(i32(1), _); // fn(comptime(y) : i32) -> comptime(i32)
result :: add1(i32(2)); // 3
_is only valid in arguments to comptime functions (functions withcomptimereturn type)- The number of arguments must match the original function's parameter count
_cannot be used with runtime functions
return requires parentheses
return expr is invalid. Use return(expr) or return() for unit. Inside match/cond branches, use begin blocks when you need early return:
// WRONG — paren-less return:
match(opt,
.Some(p) => return str.from_raw_parts(p, len),
.None => return ""
)
// CORRECT — explicit return calls:
match(opt,
.Some(p) => {
return(str.from_raw_parts(p, len));
},
.None => {
return(str.from_raw_parts((*u8)(""), usize(0)));
}
)
Better yet, if the entire function body is just a match/cond expression, use the expression form (no body block) to avoid needing return at all:
// BEST — expression form, no return needed:
raw_bytes : (fn(self: Self) -> RawSlice(u8))(
match(self._bytes._ptr,
.Some(p) => RawSlice(u8)(ptr : p, len : self._bytes._length),
.None => RawSlice(u8)(ptr : (*u8)(""), len : usize(0))
)
)
Pattern forms in match (real pattern matching, 2026-09-19)
Patterns are ordinary expressions the parser already produces; the evaluator
compiles them into a pattern IR (src/pattern.yo, plans/MATCH_PATTERN_MATCHING.md).
Every infix pattern needs its own parentheses (no operator precedence):
| form | example | meaning |
|---|---|---|
_ |
_ => … |
wildcard |
| identifier | other => (other + 1) |
binds the value — unless the name is a :: constant in scope holding a literal or enum value, which is then COMPARED (TEN => …, RED => …) |
| constant | 0, -1, true, 'a', "lit", i32(5), Color.Red |
compared with the language's own ==; a str literal matches a String or str scrutinee |
| variant | .V, .V(p, q), .V(label : p), .V({ a, b : p }) |
sub-patterns may be ANY pattern, at any depth: .Ok(.Some(v)), .Tag("a"), .Of((48..=57)) |
| or | (.Red | .Green) => …, (1 | 2) => …, .Some((.A | .B)) |
alternatives must bind the same names |
| range | (0..10) => …, (10..=19) => … |
compile-time bounds; half-open / inclusive |
| whole-value binding | (whole := .Some(v)) => … |
binds the value AND matches the sub-pattern |
| guard | (.Some(v) && (v > i32(100))) => … |
the arm runs when the pattern matches and the guard (which sees the bindings) is true |
Rules that follow:
- Exhaustiveness is structural (usefulness check):
.Some(true), .Noneis rejected withMissing case: .Some(false); integers, floats and strings need a_or binding arm; a guarded arm never counts as covering. - An unreachable arm is an error (
E0608): a duplicate variant or literal, or any arm after a catch-all. A trailing_after complete coverage is tolerated. - Diagnostic codes:
E0607not exhaustive,E0608unreachable arm,E0609invalid pattern (yo explain E0607). - Inside an
io.asyncarm that awaits, only the classic shapes (_,.V,.V(binders / numeric literals), labeled/curly binders) are lowered today; the new forms fail loudly at codegen. Bind the payload and match again inside the arm, or move the await out of the arm. - Struct/tuple scrutinees and patterns through
Box(...)payloads are not supported yet (P4).
Match destructuring forms
Match arms support three destructuring shapes for enum variants. All three coexist (different arms can use different forms within the same match):
Shape :: enum(
Circle(radius : i32),
Rectangle(width : i32, height : i32),
Triangle(base : i32, height : i32, label : str)
);
match(s,
// ✅ Preferred — Curly shorthand: `{a, b: c}` names only the fields
// the arm uses. Order-free, partial matches allowed.
.Triangle({base, height: h}) => (base * h),
// Also OK — Labeled `(label: var)` pairs. Order-free, partial matches OK.
.Circle(radius: r) => (r * r),
// ⚠️ Avoid for variants with 2+ fields — Positional ordering with `_`
// padding is brittle (adding a field shifts every later position)
// and hard to read (each `_` requires counting fields). Fine when
// every field is named *and* the variant has one or two fields.
.Rectangle(w, h) => (w * h)
)
Preferred form: curly shorthand .Variant({field1, field2: alias}) —
names only the fields the arm needs, so adding a new field to the variant
later does not silently shift positions in every arm. The
tests/match_curly.test.yo spec covers this form end-to-end.
Curly destructuring rules:
{a}binds fieldato a variable nameda(label = name shortcut).{a: x}binds fieldato a variable namedx(rename).{a: _}asserts fieldaexists but ignores its value.- Partial matches are allowed:
{width}onRectangle(width, height)skipsheight. - Empty
{}is allowed:.Variant({})matches the variant and binds NO fields (the zero case of partial curly). Bare.Variant(no parens) does the same — both work even for variants WITH fields ("ignore all fields"), so you don't need.Variant(_, _, …). (Intentionally more permissive than Rust.)tests/match_bind_nothing.test.yois the spec. - Bare
_(e.g.,{_}) is rejected — use{label: _}to ignore a specific field. - Nested curly
.Foo({a: {b}})is rejected (struct patterns are not supported yet) — but a nested VARIANT pattern in a curly slot is fine:.Foo({ a : .Some(x) }).
The parser rewrites {...} to _(...) and turns bare atoms into (name: name) pairs at parse time, so internally curly form is just a labeled-destructuring pattern wrapped in _(...). The match evaluator unwraps that wrapper.
String literal types
- Double-quoted strings
"hello"returnstr(the BUILTIN view of static string bytes) at runtime, butcomptime_strat compile time. comptime_strdoes NOT automatically convert tostrin return statements. Usestr.from_raw_parts((*u8)("..."), usize(N))if you need a runtimestr.(*u8)("literal")works — castingcomptime_strto pointer is valid.- Only pointer-to-pointer and
comptime_str-to-pointer casts are allowed. Integer-to-pointer casts like(*void)(usize(0))are NOT supported. - Template strings for constant
Stringvalues: Use`hello`instead ofString.from("hello"). Template strings without interpolation produce the sameStringresult in fewer characters. - A
"..."literal is never aString. It widens tostr,*(u8)and*(char)only, so passing one to aString(or any other non-string) parameter is E0601 — write`hello`orString.from("hello"). The same holds for1.5into an integer parameter. (Until 2026-09-23 a plain non-generic call skipped this check and miscompiled:issues/fixed/comptime-literal-argument-not-checked-against-parameter.md.) - An interpolation body is code. Inside
${...}a string literal keeps its OWN escapes and a brace or backtick inside it is not a delimiter:`${s.concat("\n")}`,`${f("}")}`and nested`${`in${x}`}`all work, and a diagnostic inside a body points at its real file line and column. (Fixed 2026-09-23,issues/fixed/template-interpolation-body-is-not-scanned-as-code.md; a SEED older than that fix still mis-lexes these forms, sosrc/andstd/avoid them untilSEED_VERSIONcarries it.) - The two string forms have DIFFERENT escape tables, and an unknown escape is silently literal in both.
\n \t \r \\ \" \' \0 \b \f \vwork in both;\`and\$only in a template.\unow works in BOTH forms (sharedscan_unicode_escape,src/utils.yo):\uXXXXtakes exactly four hex digits and\u{...}one to six (max 10FFFF, surrogates rejected); a\uXXXXhigh surrogate directly followed by a\uXXXXlow surrogate combines, JSON-style. The "" string decodes escapes at evaluation, the template at LEX time — but the scanner is shared, so the two cannot disagree.\xNNstill exists nowhere. History: 2026-09-11`x\u0041y`kept all 8 bytes while"x\u0041y"wasxAy, and a malformed"\uZZZZ"decoded to NUL because each non-hex digit counted as 0 — the fix added the shared scanner AND made the lexer reject a malformed\uwith a positioned diagnostic in both forms (issues/fixed/unicode-escape-accepts-non-hex-digits.md). Write a control byte withpush_byte, never an escape. - String indexing is BYTE-based (D4, 2026-08-26):
len()is the byte count (O(1));at/substring/s(a..b)/index_ofand every positional string argument speak byte offsets, at compile time (comptime_str) and at runtime alike.substringpanics on an offset inside a rune (try_substringis the non-panicking form); rune work goes throughchars()/char_indices(), and the rune count iss.chars().count(). Full contract:docs/en-US/STRINGS.md; pitfalls: the string-indexing section of.github/skills/yo-syntax/syntax-cheatsheet.md.
Trait method dispatch syntax
Implicit dispatch (via where-clause)
When a generic function has where(T <: Trait), calling self.method() on a parameter of type T dispatches to Trait's method:
use_t1 :: (fn(generic(T : Type), self : T, where(T <: T1)) -> i32)({
return(self.get_number()); // Dispatches to T1.get_number
});
Explicit trait dispatch
Use (T <: Trait).method(self) to explicitly select which trait's method to call:
use_t2 :: (fn(generic(T : Type), self : T, where(T <: T2)) -> i32)({
return((T <: T2).get_number(self)); // Explicitly calls T2.get_number
});
This is necessary when:
- A type implements multiple traits with the same method name
- You want to be explicit about which trait's method is called
- The
selfparameter type doesn't uniquely determine the trait
impl(...) requires a trailing semicolon
impl(...) is a statement and requires a trailing ; at the top level:
// WRONG — missing semicolon causes "Invalid function call on type":
impl(MyType,
get : (fn(self : Self) -> i32)(self.x)
)
// CORRECT:
impl(MyType,
get : (fn(self : Self) -> i32)(self.x)
);
Reserved keywords cannot be used as variable or field names
The word type is a reserved keyword in Yo. Never use it as a parameter name, field name, or variable name:
// WRONG — `type` is reserved:
Variable :: ref(struct(name : String, type : TypeValue));
define :: (fn(ty : TypeValue) -> unit)(...) // CORRECT, use `ty`
// CORRECT — rename to `ty`:
Variable :: ref(struct(name : String, ty : TypeValue));
Other reserved words to avoid as identifiers: fn, type, trait, impl, enum, struct, ref, atomic, inout, newtype, match, cond, if, while, for, return, unwind, recur, export, import, using, given, generic, where.
___ (discard) cannot be used twice in the same scope
Yo does not allow redeclaring ___ twice in the same begin-block scope. Each use is a fresh variable binding and shadowing is not allowed:
// WRONG — second `___` shadows the first, causing a compile error:
___ := foo();
___ := bar();
// CORRECT — use unique names, or call without binding:
_a := foo();
_b := bar();
// ALSO CORRECT — if you don't need the results:
foo();
bar();
Prefer the bare call over _ := foo(); / ___ := foo(); when the result is unused (a 2026-09-11 tree sweep removed 45 such discard bindings). Two cases where the binding is load-bearing — leave it: drop/borrow fixtures that count rc(...) or test the discard's own scope-end drop, and compile-error fixtures whose diagnostic fires only on the value-evaluation path a binding forces (a bare statement can skip it — e.g. the list(i).* clear-error fixture in tests/collections/array_list.test.yo errors under _ := bad_list(usize(0)).*; but passes as a bare bad_list(usize(0)).*; statement).
ArrayList indexing via arr(index)
ArrayList(T) implements the Index trait, so elements can be accessed with call syntax:
{ ArrayList } :: import("std/collections/array_list");
list := ArrayList(i32).new();
list.push(i32(10));
list.push(i32(20));
val := list(usize(0)); // → i32 (value copy)
list(usize(0)) = i32(99); // mutate in place directly (preferred)
// When you need the pointer explicitly:
ptr := &(list(usize(0))); // → *(i32)
ptr.* = i32(100);
*Truncated - read the full file at https://github.com/shd101wyy/Yo/blob/5498dd70b59aea6e7cea740ac1b8ff39f05a97f5/.github/instructions/yo-syntax.instructions.md.*