Skip to content

Source And Grammar

This chapter documents Zynx source tokens, grammar shape, inline assembly syntax, and formatter policy for context-sensitive forms.

Status: current public API unless a rule is marked unsupported or unspecified.

Source And Tokens

Zynx source is tokenized into keywords, built-in type names, identifiers, literals, and punctuators.

Whitespace separates tokens and is otherwise insignificant. Line comments start with // and continue to the end of the line. Block comments start with /* and continue through the next */; they may span multiple lines and are not nested. Comments are ignored like whitespace and comment delimiters inside string literals are ordinary string text.

Identifiers use the Unicode 17.0.0 UAX #31 default identifier profile. The first Unicode scalar value must be _ or have the XID_Start property. Each following scalar value must have the XID_Continue property. Source files are decoded as strict UTF-8 for identifier recognition; malformed UTF-8 in an identifier is a compile-time error. Identifier spellings are normalized to NFC before comparison, so canonically equivalent spellings resolve to the same symbol. The identifier character set is version-pinned to Unicode 17.0.0, the same data version exposed by std.unicode.version.

Struct and enum type identifiers in user source must use upper camel case: they must start with an uppercase ASCII letter and every following character must be an ASCII letter or digit. Underscores are not allowed in these type identifiers. This is a language rule, not a lint. It does not apply to fields, methods, functions, local variables, imports, enum variants, or error-set variants.

The keyword set is:

text
break const continue else enum fn for if import in let var loop match
return struct union while defer await asm as interface test error
catch try throw assert throws type distinct export extern intrinsic unsafe with

The built-in type-name token set is:

text
i8 i16 i32 i64 i128
u8 u16 u32 u64 u128
iN uN        // integer widths 1 <= N <= 65535, no leading zeroes
f16 bf16 f32 f64 f128
usize isize
str bool null void never

unique is a reserved language identifier with a special contract, but it is not a keyword token. The removed marker spellings Copyable and ImplicitCopy are ordinary identifiers in every scope and declaration kind. They have no reservation or tombstone and may be declared, imported, or shadowed normally.

Language-reserved nominal forms use canonical upper camel case, including Vector<T, N>, Matrix<T, R, C>, and Unique<T>. Marker interfaces such as Copy, Scalar, Integer, ByteAlignedInteger, Signed, Unsigned, and Float follow the same casing convention.

Integer literals support decimal, 0x/0X hexadecimal, 0o/0O octal, and 0b/0B binary forms. A leading + or - is part of a numeric literal only when the previous token does not end an expression; otherwise it is parsed as an operator. Integer literals may carry _iN or _uN suffixes for builtin integer targets, including custom widths from 1 through 65535 bits. Floating-point literals are decimal numeric literals with either a decimal point followed by a digit or a scientific exponent (e/E with an optional sign and at least one digit). For example, 1.0, 1e3, and 1.5e-2 are floating-point literals. Hexadecimal, binary, and octal literals do not support exponents.

String literals use double quotes. Escaped strings process \n, \t, \r, \e, \\, \", \', \xNN, and \u{...}. Any other escape sequence is a compile-time error. Raw strings use backticks, for example `{"name":"app"}`. A doubled backtick denotes one backtick in the value; raw strings process no other escape and never interpolate. Multiline strings use triple double quotes (""") for escaped text and triple backticks (```) for raw text. Multiline literals must put content on the line after the opening delimiter; the newline after the opening delimiter and the newline before the closing delimiter are not part of the value. The whitespace prefix before the closing delimiter is removed from every nonblank content line. After escape processing, a string literal must be valid UTF-8 text. U+0000 is accepted in ordinary text; only the separate c"..." form rejects a decoded NUL byte.

Single quotes denote exactly one Unicode scalar of type rune, for example 'x', 'é', '😀', or '\0'. The ordinary string escape vocabulary applies inside the quotes. Empty, multi-scalar, surrogate, out-of-range, and malformed rune literals are compile-time errors; single quotes never denote raw strings or byte literals.

A hole-free string literal has type str and is a program-lifetime shared view over static read-only bytes. Expected type never turns it into ^str, and static literal storage cannot be mutably borrowed as &str. The closed ^"..." form is the only owning-string literal; the same prefix applies to raw and multiline delimiters. Any hole-free owning literal whose decoded content is statically empty is allocation-free and infallible, including empty raw and trimmed multiline forms. Every statically nonempty hole-free owning string and every interpolated owning string has checked effect AllocError, even when an interpolation renders no bytes.

The literal's str value exposes only its decoded byte range. Even if a backend places a zero byte after that range, no text role receives a ptr[size] == 0 guarantee. Prefixing the literal with ^ creates an owner, not a C string; dynamic terminated bytes require explicit checked std.ffi.CStr construction.

The separate closed c"..." form creates a program-lifetime std.ffi.CStr. It never allocates or interpolates. Ordinary source characters and \u{...} escapes contribute UTF-8 bytes, while \xNN contributes the exact byte. Every form that decodes a zero byte is rejected. There are no raw, multiline, interpolated, or owning c literal variants; dynamic terminated bytes use explicit ^std.ffi.CStr construction.

Active {expression} and {expression:spec} holes are legal only in an owning escaped literal, such as try ^"value={value}". Interpolation never creates a hidden shared or stack-backed str temporary.

MVP Grammar

This grammar is normative for source shape. Later semantic sections can reject grammatically valid programs, for example invalid overloads, invalid borrows, non-error names in throws(...), or unavailable interface methods.

Grammar notation:

  • quoted strings are literal tokens,
  • lowercase names are nonterminals,
  • ? means optional,
  • * means zero or more,
  • + means one or more,
  • | separates alternatives,
  • punctuation inside quotes is source punctuation,
  • identifier, literal, string-literal, and builtin-type are token classes from the lexer.

The operator-precedence table in Operators And Evaluation is part of the grammar for expression; productions below define expression atoms and special expression forms.

Modules And Declarations

EBNF
source-file        ::= module-item* EOF

module-item        ::= import-decl
                     | export-import-decl
                     | decl-prefix? (extern-abi-decl | top-level-decl)
decl-prefix        ::= attribute* decl-modifier*
decl-modifier      ::= "export" | "extern" | "intrinsic"
attribute          ::= "@" attribute-name call-args?
attribute-name     ::= identifier

top-level-decl     ::= function-decl
                     | struct-decl
                     | enum-decl
                     | error-decl
                     | interface-decl
                     | type-alias-decl
                     | test-decl
                     | variable-decl ";"

import-decl        ::= "import" module-path import-alias? ";"
                     | "import" module-path ".{" import-symbol
                       ("," import-symbol)* ","? "}" ";"
export-import-decl ::= "export" "import" module-path ".{" import-symbol
                       ("," import-symbol)* ","? "}" ";"
import-symbol      ::= identifier import-alias?
import-alias       ::= "as" identifier
module-path        ::= identifier ("." identifier)*

extern-abi-decl    ::= "extern" string-literal ("fn" extern-abi-fn-tail
                                               | ("let" | "var" | "const") declaration ";"
                                               | "{" extern-abi-item* "}")
                     | "extern" "library" string-literal
                       "{" extern-library-item* "}"
extern-abi-item    ::= "abi" string-literal ";"
                     | attribute* ("fn" extern-library-fn-tail
                                  | ("let" | "var" | "const") declaration ";"
                                  | struct-decl
                                  | union-decl
                                  | enum-decl)
extern-library-item ::= extern-abi-item
                     | "link" string-literal ";"
extern-abi-fn-tail ::= identifier generic-params? param-list function-return?
                       (";" | block)
extern-library-fn-tail ::= identifier generic-params? param-list function-return? ";"

test-decl          ::= "test" string-literal block

module declarations are not part of the grammar; module names are derived from file paths and import requests.

The grammar admits the exact host-loan declaration through an attributed, typed, uninitialized module let:

Zynx
// RFC 0066/0076 target; implementation pending.
@host let project: fs.Dir in _;
@host let outbound: net.Network in _;
@host let registers: mmio.Region in _;

This is a compiler-recognized declaration class, not an ordinary uninitialized global. Every token shown above is required: @host takes no arguments, the binding is let, the nominal capability type is explicit, in _ explicitly requests the private instance region, and there is no initializer. The closed type set is exactly std.fs.Dir, std.net.Network, and std.mmio.Region.

Host loans are legal only in the selected executable composition-root module which contains main. They are always private and only the direct body of the parameterless main may resolve their names. Helpers, closures, dependencies, tests, libraries, exports, module initializers, and foreign entries receive no special lookup; main delegates authority to them through ordinary explicit parameters. An unused host loan is rejected.

An ordinary module import is namespace-only. Short names enter scope only through the selective form. The sole re-export production is the selective export-import-decl; export import M;, export import M as A;, implicit or explicit wildcard imports, and extern/intrinsic import prefixes are not grammar aliases. A selective alias introduces only its alias.

extern "C" declarations and blocks describe foreign ABI functions and globals. At top level, a semicolon imports a foreign symbol while a body defines a private C-calling-convention function. Prefixing that definition with export additionally publishes its unmangled symbol; calling convention and symbol visibility are independent. An extern library block remains declaration-only. extern library "name" { ... } additionally names the native library group for the block and may contain link "name"; directives. A link name is a bare library name such as "z"; paths and linker flags such as "/usr/lib/libz.a" or "-lz" are rejected. link directives are not valid outside extern library blocks.

Types

EBNF
type               ::= type-prefix? type-atom type-pack? nullable?
                       region-annotation? error-union? future-capability?
type-prefix        ::= "&" | "^" | pointer-prefix
pointer-prefix     ::= "*"+ "mut"?
type-pack          ::= "..."
nullable           ::= "?"
region-annotation  ::= "in" identifier
error-union        ::= throws-clause

type-atom          ::= builtin-type
                     | type-name generic-args?
                     | array-type
                     | grouped-type
                     | tuple-type
                     | callable-type
                     | c-function-pointer-type

type-name          ::= identifier ("." identifier)?
array-type         ::= "[" type (";" shape-arg)? "]"
grouped-type       ::= "(" type ")"
tuple-type         ::= "(" type "," type-list? ")"
type-list          ::= type ("," type)* ","?
callable-type      ::= callable-params throws-clause? "->" type
                       callable-capability?
callable-params    ::= "(" callable-param-list? ")"
callable-param-list ::= callable-receiver ("," type-list)?
                      | type-list
callable-receiver  ::= "&" "self" | "^" "self"
callable-capability ::= "where" callable-capability-list
callable-capability-list ::= "Send" | "Sync" | "Send" "+" "Sync"
c-function-pointer-type ::= "extern" string-literal "fn"
                            "(" type-list? ")" "->" type
future-capability  ::= "where" "Send"
throws-clause      ::= "throws" "(" error-set-union ")"
error-set-union    ::= error-set-ref ("|" error-set-ref)*
error-set-ref      ::= module-path

generic-params     ::= "<" generic-param ("," generic-param)* ">"
generic-param      ::= identifier generic-bound? generic-default?
                     | identifier "..."
                     | "region" identifier
generic-bound      ::= ":" (const-generic-type | type-bound-list)
type-bound-list    ::= type ("&" type)*
const-generic-type ::= integer-builtin-type
generic-default    ::= "=" (type | shape-arg)

generic-args       ::= "<" generic-arg ("," generic-arg)* ">"
generic-arg        ::= type | shape-arg
shape-arg          ::= integer-literal | const-generic-name
                     | "{" shape-expression "}"
const-generic-name ::= identifier
shape-expression   ::= shape-primary
                     | ("+" | "-" | "~") shape-expression
                     | shape-expression shape-binary-op shape-expression
                     | shape-expression "as" integer-builtin-type
shape-primary      ::= integer-literal | identifier
                     | "(" shape-expression ")"
shape-binary-op    ::= "+" | "-" | "*" | "/" | "%"
                     | "<<" | ">>" | "&" | "|" | "^"

Bare types are shared, & is mutable, ^ is owned, * is read-only raw, and *mut is writable raw. Roles recurse through arrays and generic arguments. Pointer depth remains flat (**T, **mut T); mixed per-level qualifiers are not supported. const is only a binding keyword and is rejected before a type.

The accepted C-function-pointer type is written extern "C" fn(Args...) -> R. It is a non-null opaque code-pointer value, distinct from both raw data pointers and environment-carrying Zynx callable types. Nullable C callbacks use an alias followed by ?, or a grouped inline type such as (extern "C" fn(i32) -> i32)?; *mut (i32) -> i32 is not an alternate spelling. Only ABI "C" is accepted.

Generic defaults are public on type declarations: structs, interfaces, enums, and type aliases. A type parameter default is a type, and a const-generic default is a shape argument that may refer only to earlier parameters. Parameters with defaults must be trailing, except that region parameters do not count as value-supplied parameters. Function and init generic parameters reject defaults.

Shape expressions are evaluated in the target's checked usize context after named const aliases are expanded. Atomic dimensions omit braces (4, N); compound dimensions require them ({N + 1}). Parentheses do not remove that outer-brace requirement. A const parameter of another integer type requires an explicit as usize cast.

throws(...) belongs after the nullable suffix when written on a value type: T? throws(E). Function declarations and callable types write thrown error sets before the return arrow: throws(E) -> T. In function declarations, methods, operators, init, and closures, throws(E) without a return arrow is shorthand for throws(E) -> void.

type-name supports ordinary names and module-member names such as async.Future or io.IOError. Import deeper modules under a local binding before using their members in written types.

Functions And Members

EBNF
function-decl      ::= function-modifier* "fn" identifier generic-params?
                       param-list function-return? function-body
function-modifier  ::= "unsafe"
function-return    ::= function-throws? "->" type
                     | function-throws
function-throws    ::= throws-clause | "throws"
function-body      ::= block | ";"

param-list         ::= "(" param-seq? ")"
param-seq          ::= "..."
                     | typed-variadic-param
                     | param ("," param)* ","?
param              ::= identifier ":" type default-value? param-pack?
                     | ("&" | "^")? "self" region-annotation?
default-value      ::= "=" expression
param-pack         ::= "..."
typed-variadic-param ::= builtin-type "..."

struct-decl        ::= "struct" identifier generic-params? base-list?
                       "{" struct-member* "}"
union-decl         ::= "union" identifier generic-params? base-list?
                       "{" union-member* "}"
base-list          ::= ":" type ("," type)*

struct-member      ::= decl-prefix? struct-field-decl
                     | decl-prefix? method-decl
                     | init-decl
                     | drop-decl
union-member       ::= decl-prefix? field-decl
                     | decl-prefix? method-decl
                     | init-decl
                     | drop-decl
struct-field-decl  ::= field-mutability identifier ":" type attribute*
                       default-value? ("," | ";")?
field-decl         ::= field-mutability? identifier ":" type attribute*
                       default-value? ("," | ";")?
field-mutability   ::= "var" | "let"
method-decl        ::= function-decl
init-decl          ::= "init" generic-params? param-list
                       function-return? block
drop-decl          ::= "drop" param-list block

interface-decl     ::= "interface" identifier generic-params? base-list?
                       (";" | "{" interface-member* "}")
interface-member   ::= field-decl
                     | method-decl

enum-decl          ::= "enum" identifier generic-params? enum-backing?
                       "{" enum-variant* "}"
enum-backing       ::= ":" builtin-type
enum-variant       ::= identifier enum-value? enum-payload?
                       ("," | ";")?
enum-value         ::= "=" expression
enum-payload       ::= param-list
enum-literal       ::= enum-type "." identifier struct-literal?
                     | contextual-member struct-literal?
enum-type          ::= type-name generic-args?

error-decl         ::= "error" identifier "{" error-variant* "}"
error-variant      ::= identifier ("," | ";")?

type-alias-decl    ::= "type" identifier generic-params?
                       "=" "distinct"? type ";"

@target_feature is a declaration-prefix attribute whose argument list contains one or more expressions expected as closed std.cpu.Feature values. Canonical source uses inferred enum variants, for example @target_feature(.X86Avx2, .X86Fma). The former string/comma-list and +/- forms are not grammar alternatives. The attribute is invalid on main and on extern declarations.

Only C ABI enums declared inside extern library blocks may use explicit enum-value discriminants. Ordinary Zynx enums use implicit tags starting at 0. If an enum has a backing type, every implicit or explicit tag must be representable in that backing type. Duplicate discriminants are rejected. C ABI enum discriminants must be integer literals; decimal, hexadecimal (0x), binary (0b), and octal (0o) spellings are accepted. Enum discriminants must fit a signed 32-bit range.

self is a special method parameter spelling. When it is the first parameter inside a struct or interface method, its type is inferred from the enclosing type. A bare self is a shared read-only receiver borrow; &self requests an exclusive mutable borrow and ^self an owned, consuming receiver. self in L and &self in L tie a borrowed result to a region. A method with a first self parameter is an instance method. A method without a first self parameter is a static method and is called through the type or module-qualified type expression.

A method can constrain the enclosing struct type parameter by repeating that name in its method generic list. For example, a hypothetical fn inspect<T: Copy>(self) ... constrains T on the enclosing Set<T> instead of introducing a fresh method generic and therefore makes the method available only when the owning Set element type satisfies the bound.

Struct fields accept attributes either as a declaration prefix or as suffix attributes after the field type and before the default value. This is equivalent source shape:

Zynx
struct Point {
    @readonly var x: i32,
    var y: i32 @readonly,
}

fn main() {}

Every stored struct field requires exactly one explicit var or let. A bare field: T is a hard syntax error. A var field is initialized during construction and may later be assigned only through exclusive access to the containing value. A let field is initialized exactly once during construction and cannot later be assigned by any caller or method. var field: T stores a shared borrow, var field: &T an exclusive borrow, and var field: ^T an owner; this role is orthogonal to the field-mutability keyword and the same roles are available to let fields. @readonly var further restricts post-construction assignment to methods of the declaring struct; it does not replace let. Enum and error payload parameters use param-list rather than struct-field-decl and are unchanged.

Explicit Constructors And Conversions

Every struct init is direct-only and is invoked exclusively by a written Type(args...) constructor expression. A no-argument initializer is called as Type(), and a fallible initializer is called with try Type(args...). unsafe init is not valid syntax, there is no async constructor syntax, and neither @implicit nor @explicit is a valid init attribute.

Zynx
struct Token {
    let value: i64,

    init(value: i64) {
        self.value = value;
    }
}

let raw: i64 = 7;
let token = Token(raw);       // explicit init call
let rejected: Token = raw;   // no hidden Token.init(raw)
consume(Token(raw));          // explicit at the argument boundary

Variable initialization, assignment, a selected call parameter, return type, overload result, and every other expected-type context check the value already produced; they do not search for an initializer and do not insert user code. Semantic, fallible, and allocating conversions use domain-specific named functions or methods and must be called explicitly. A language as cast is available only where its own closed cast rule says so.

Compiler-known contextual literals remain construction syntax rather than conversion hooks. An exact numeric literal may receive its declared numeric type, and Point { ... } or a contextually typed { ... } constructs a struct without calling init. The separately documented array, Vector, Matrix, enum, and other literal forms remain closed compiler rules; they cannot be extended by declaring an initializer. A future builtin coercion requires a separate closed language decision, and none is added here.

The implicit-constructor candidate search and hop rules no longer exist. --trace-implicit-constructors is removed rather than retained as a no-op and is diagnosed like any other unsupported compiler option.

Statements

EBNF
block              ::= "{" statement* "}"

statement          ::= variable-decl ";"
                     | return-stmt ";"
                     | throw-stmt ";"
                     | assert-stmt ";"
                     | discard-self-stmt ";"
                     | "break" ";"
                     | "continue" ";"
                     | asm-expr ";"
                     | defer-stmt
                     | if-stmt
                     | while-stmt
                     | loop-stmt
                     | for-stmt
                     | scope-with-stmt
                     | unsafe-block
                     | block
                     | statement-match
                     | statement-catch
                     | expression ";"

unsafe-block       ::= "unsafe" block

variable-decl      ::= ("let" | "var" | "const") variable-binding initializer?
                     | ("let" | "var") tuple-binding "=" expression
variable-binding   ::= identifier (":" type attribute*)?
initializer        ::= "=" expression
tuple-binding      ::= "(" identifier ("," identifier)* ","? ")"

return-stmt        ::= "return" expression?
throw-stmt         ::= "throw" expression
assert-stmt        ::= "assert" expression ("," expression)?
discard-self-stmt  ::= "discard" "self"
defer-stmt         ::= "defer" defer-effect? (block | expression ";")
defer-effect       ::= "await"

if-stmt            ::= "if" expression block
                       ("else" (if-stmt | block))?
while-stmt         ::= "while" expression block
loop-stmt          ::= "loop" block
for-stmt           ::= "for" for-effect? for-binding "in" expression block
for-effect         ::= "try" | "await" | "try" "await"
for-binding        ::= identifier | tuple-binding | identifier ":" type
scope-with-stmt    ::= try-prefix? "await" "with" expression
                       "as" identifier block
try-prefix         ::= "try"

statement-match    ::= match-stmt ";"?
statement-catch    ::= catch-expr ";"?

discard remains an ordinary identifier except in the exact contextual discard self; statement. That statement is lifetime-terminal for the consuming receiver, not a general expression and not a control-flow exit. It is legal only in an original concrete struct method whose receiver is ^self; it is rejected in drop(&self), extensions, free functions, closures, and interface defaults. It makes self unavailable, skips only that nominal type's user drop(&self), and immediately reverse-drops every still-initialized residual field. Code may continue using values moved out before the statement. A method that contains discard self must explicitly resolve self on every non-diverging control-flow path; an implicit receiver cleanup at a return or checked-error edge is rejected. A statically named field may be consumed before discard, but the resulting partial receiver cannot cross a suspension, propagating failure, nonlocal exit, defer boundary, or nested function/closure body. discard self itself is rejected inside drop, defer, and defer await bodies.

Ordinary defer is synchronous and rejects suspension. defer await is the only suspending cleanup spelling. Both bodies must reach their boundary through one normal, infallible continuation: direct outward return, escaping throw, a failed or propagating try, and break/continue targeting outside the cleanup body are rejected. Loops and matches nested wholly inside cleanup may use their own break/continue, and control inside a called function or closure belongs to that nested function. Every checked error must be caught and handled inside cleanup. A runtime trap still aborts immediately and skips remaining cleanup.

scope-with-stmt is the reserved RFC 0009 composite, not a general with production. Its expression must resolve to the compiler-known async.Scope.local() or async.Scope.parallel() constructor. The optional try is present exactly when the Scope can fail, and await is mandatory. Bare with expr as name { body }, await with over any other expression, and types that imitate Scope methods are hard errors; the language has no enter/exit or context-manager protocol.

An ordinary resource binding is written explicitly:

Zynx
{
    let resource = open_resource();
    defer { resource.flush(); }
    use(resource);
}

The initializer runs before the body. On a language exit, written defers run in LIFO order and then live locals drop at the lexical block boundary. Omit the defer when ordinary drop is sufficient. This explicit block is not a spelling for Scope: [try] await with async.Scope.<mode>() as scope { ... } retains its mandatory cancel-and-join close.

statement-match and statement-catch may omit the trailing semicolon when they occupy the whole statement. match is a statement and does not appear in expression position. A catch used inside a larger declaration, assignment, return, or expression follows that larger statement's semicolon rule.

for name in xs binds each yielded element to name. for (a, b) in xs destructures each yielded tuple element with the same flat tuple-binding rules as let (a, b) = elem. Arity mismatches and tuple-pattern loops over non-tuple elements are compile errors. A loop never synthesizes an index or key; produce an explicit owning cursor and write for (i, value) in std.iter.enumerate(xs.iter()) when an index is needed.

The typed binding supports explicit lending iteration. In for item: &T in _ in &items { ... }, the first in _ belongs to the type's required inferred local region and the second in is the loop delimiter. Mutable lending iteration does not support tuple destructuring.

The synchronous loop expression is evaluated once. If its type has an accepted synchronous cursor-shaped next, for drives that exact cursor and never calls an iter method on the same type. This direct-cursor classification wins; an effect or binding mismatch is diagnosed without a fallback. A method merely named next but matching none of the accepted owning, shared, or lending cursor shapes does not block structural source resolution.

A named direct cursor is exclusively borrowed for the complete loop rather than consumed. Its name is unavailable in the body, but break ends the loop loan and preserves the exact current cursor position for a later loop or direct pull. A fresh cursor temporary is owned by the loop and drops at loop exit. Writing <-cursor explicitly moves a named direct cursor into that temporary state, so early exit drops its remainder rather than making it resumable.

Only when there is no direct cursor-shaped next does a concrete source create its cursor once before the first pull. A fallible source expression or fallible structural iterator acquisition is not covered by the loop header and needs a separate written try, either in an earlier binding or after in. There is no Iterable source interface or implicit cursor copy.

Structural source access selects the receiver. for item in items resolves only shared/copy iter(self), for item in &items resolves only iter(&self), and for item in <-items moves a named collection once and resolves only iter(^self) -> ^ConcreteOwnedIter<T>. A missing selected overload is a hard error with no shared fallback, copy, clone, or interface conversion. A fresh owned temporary needs no marker, as in for item in try make_items(). The old for item <- items order is not grammar.

An owning iterator returns a fresh ^T from next() after disarming the source slot. The plain loop binding is an ordinary owned local; pass it on with <-item or let it drop at the end of the iteration. The iterator owns remaining armed elements and storage. A temporary cursor owned by the loop drops that remainder on early exit. A named direct cursor instead retains it after break, because the loop held only an exclusive cursor loan.

for value in source requires repeated next() -> T?. for try value in source requires repeated next() -> T? throws(E) and propagates each failure before creating the iteration binding or entering the body. Missing try for a fallible next() and redundant try for an infallible next() are hard errors. The enclosing function, Future payload, or Generator contract must admit E. A failure drops the iterator and exited locals exactly once before the checked error leaves. Local recovery uses an explicit loop around next() catch { ... }; for catch is not a grammar form.

Normal null exhaustion is sticky for every accepted cursor shape. Every later next returns null without touching an underlying source, invoking user code, allocating or deallocating, performing I/O or entropy work, mutating observable state, or throwing. A lending null carries no loan. A checked failure is not an exhaustion result.

for await value in source and for try await value in source are reserved in that exact order for a separately accepted async multi-item protocol. No current source is eligible. In particular, async_iter() and a Future-returning next() do not opt a user type into async iteration, and the removed Awaitable protocol is not consulted. for await try is a syntax error, not an alias for for try await.

A shared for item in items loop borrows the collection for the whole body: the iterator aliases its storage, so mutating that collection mid-iteration is rejected. A consuming for item in <-items instead moves the source before the loop, making every later source use a use-after-move error.

Mutable lending iteration has exactly the form for item: &T in _ in &items. Source-side &items selects only iter(&self); the binding type and its later use never select an overload. The exclusive source loan lasts for the complete loop, and each successful next creates a fresh item region that ends before the next backedge. The source name is inaccessible in the body, while mutation through item is legal. The item cannot escape the iteration or be moved with <-. for &item in items, a bare for item in &items, a shared for item: &T in _ in items, iter_mut, and mutable tuple destructuring are not aliases.

A projection-lending cursor may instead return an affine region-typed View? in Step containing only shared or exclusive loans plus copy metadata; such a view has no owned field and no destructor. Its loop uses for view: View in _ in &items. This is the ordinary for binding-type grammar, not a recognized view or collection name. Source-side &items alone selects iter(&self); neither the binding nor later use selects an overload. Every present view ends before the next backedge, while null carries no loan.

Expressions

EBNF
expression         ::= precedence-expression

precedence-expression ::= prefix-expr
                         | precedence-expression binary-op expression
                         | catch-expr

prefix-expr        ::= postfix-expr
                     | ("!" | "+" | "-" | "~" | "*" | "&") expression
                     | "<-" expression
                     | "try" expression
                     | "await" expression

postfix-expr       ::= primary-expr postfix-part*
postfix-part       ::= "." member-name
                     | "?." member-name
                     | call-args
                     | "[" expression "]"
                     | struct-literal
member-name        ::= identifier generic-args?

primary-expr       ::= literal
                     | c-string-literal
                     | "null"
                     | void-value
                     | identifier generic-args?
                     | numeric-type-call
                     | enum-literal
                     | contextual-member
                     | grouped-expr
                     | tuple-literal
                     | array-literal
                     | owning-slice-literal
                     | owning-string-literal
                     | struct-literal
                     | builtin-expr
                     | lambda-expr

void-value         ::= "void" "(" ")"

numeric-type-call  ::= numeric-type-call-target call-args
numeric-type-call-target ::= integer-builtin-type
                           | "f32" | "f64"

grouped-expr       ::= "(" expression ")"
tuple-literal      ::= "(" tuple-element "," tuple-element-list? ")"
tuple-element-list ::= tuple-element ("," tuple-element)* ","?
tuple-element      ::= expression "..."?
array-literal      ::= "[" (expression "..."? ("," expression "..."?)* ","?
                         | expression ";" expression)? "]"
owning-slice-literal ::= "^" array-literal
owning-string-literal ::= "^" string-literal
c-string-literal   ::= "c" string-literal
struct-literal     ::= "{" (struct-field ("," struct-field)* ","?)? "}"
struct-field       ::= identifier (":" expression)?

call-args          ::= "(" (call-arg ("," call-arg)* ","?)? ")"
call-arg           ::= (named-arg | expression) "..."?
named-arg          ::= identifier ":" expression

builtin-expr       ::= "@" dotted-name generic-args? builtin-call-args?
dotted-name        ::= identifier ("." identifier)*
builtin-call-args  ::= call-args | builtin-type-call-args
builtin-type-call-args ::= "(" (builtin-type-call-arg
                         ("," builtin-type-call-arg)* ","?)? ")"
builtin-type-call-arg ::= named-arg | type | expression

lambda-expr        ::= capture-list? (lambda-signature | arrow-lambda)
capture-list       ::= "[" (capture ("," capture)* ","?)? "]"
capture            ::= identifier
                     | "<-" identifier
                     | identifier "in" "_"
                     | "&" identifier "in" "_"
lambda-signature   ::= param-list function-return? block
arrow-lambda       ::= "(" arrow-lambda-params? ")" "=>" (block | expression)
arrow-lambda-params ::= identifier ("," identifier)* ","?
                     | param ("," param)* ","?

match-stmt         ::= "match" expression "{" match-arm* "}"
match-arm          ::= match-pattern arm-body arm-separator?
match-pattern      ::= "." identifier enum-bindings?
                     | "_"
                     | literal
enum-bindings      ::= "(" identifier ("," identifier)* ","? ")"

catch-expr         ::= expression "catch" "{" catch-arm* "}"
catch-arm          ::= catch-pattern catch-binding? arm-body arm-separator?
catch-pattern      ::= "." dotted-name | "_"
catch-binding      ::= "(" identifier ")"

arm-body           ::= block | "=>" (block | expression)
arm-separator      ::= "," | ";"
binary-op          ::= one of the binary operators in the precedence table

Plain array-literal always constructs inline fixed storage and never allocates. owning-slice-literal is the sole dynamic ^[T] construction. owning-string-literal is likewise the sole literal construction of ^str. The leading ^ is part of these closed literal productions, not a general unary operator: ^value, ^make(), and ^{ field: value } are rejected. An expected ^[T] result may constrain the element type of ^[...], but it never turns a plain [...] expression into an owning slice.

c-string-literal is the sole literal construction of static std.ffi.CStr. Its c prefix is part of the token, not a conversion operator. It accepts only the single-line escaped delimiter, rejects interpolation and decoded NUL, and has no owning form. Exact ^[] is infallible. Every nonempty list and every repeat, range, or expansion owning form has AllocError, so it appears under try or catch; this effect does not disappear for a zero const count, empty computed range, or zero-sized element.

A plain hole-free string-literal always denotes a static shared str and never allocates by expected type. A hole-free owning literal is infallible when its decoded content is statically empty; every statically nonempty owning string and every interpolated owning string has AllocError regardless of the rendered length. Escaped interpolation holes are rejected without the written owning prefix.

Although both member tokens are syntactically postfix parts, their receiver types select exactly one legal spelling. . is ordinary access on a non-nullable receiver. ?. is accepted only when the resolved receiver type is genuinely nullable T?, for both fields and methods; using it on T is a hard semantic error rather than a lint. Optional access evaluates its receiver once and denotes a real null branch. It does not contextually wrap T into T? or alter the ordinary member's ownership and loan rules. An outer expected U? may still receive a non-nullable result from ordinary . through the separately defined contextual result injection.

Builtin expressions start with @ and use a dotted builtin name such as @sizeof or compiler-private @llvm.ctpop. The parser accepts optional generic arguments before a call for recognized builtins that require a type argument; parsing that shape does not make an arbitrary dotted name public. @sizeof, @min, and @max use a special parenthesized argument parser so a single type can be written as an argument: @sizeof(i32) or @max(u64). Generic nominal types are not currently parsed in that parenthesized type-argument position, so @sizeof(Map<K, V>) is not accepted by the current parser.

Direct ordinary-source @zynx.volatile.* expressions are not in the accepted builtin set. Exact volatile access is an ordinary unsafe SDK call through the eight non-generic scalar overloads of std.mem.volatile.load and std.mem.volatile.store; any semantic intrinsic used to implement those wrappers remains compiler-private.

The accepted builtin set has @drop(local) but no @forget, @leak, or generic cleanup-suppression operation. The general builtin grammar does not make an unrecognized dotted name valid, and the removed @forget experiment has no compatibility alias. Nominal alternative finalization uses the distinct discard self; statement under its ^self restrictions.

The parser distinguishes the overloaded forms above with these grammar rules:

  • Generic arguments in expressions are recognized only immediately after an identifier or member name, and only when the matching > is followed by (, {, ., or [. Otherwise < and > are comparison operators. Standalone generic instantiation expressions such as id<i32> are not part of the current expression grammar.
  • A numeric built-in type token followed by call arguments is a numeric type call, such as i32("123") or f64("3.14"). This form is restricted to the listed builtin numeric tokens and one str argument. Alias calls and generic numeric calls are follow-up work.
  • A parenthesized expression with a top-level comma or top-level ... is a tuple literal; without one it is a grouped expression.
  • void() is the sole explicit value of type void. Empty parentheses alone are not a tuple or unit literal, and () is not a standalone source type. () -> R is instead the zero-argument callable type because the following arrow fixes that grammar. An empty tuple-pack expression expansion normalizes to void() only during elaboration.
  • An erased callable type is a parenthesized parameter-type list followed by an optional checked-error clause and ->. (A, B) -> R has the implicit hidden shared/read environment receiver. &self or ^self may appear only as the first marker when the environment requires exclusive/mutating or consuming invocation; actual argument types follow an ordinary comma. Examples are (i32) -> bool, (&self, Event) -> void, and (^self) throws(JobError) -> void where Send. Zero actual arguments are () -> R, (&self) -> R, and (^self) -> R. self is not written for the shared form, and no callable type uses a semicolon.
  • When that callable-shaped syntax is the bound after a generic parameter colon, as in F: (&self, ^T) -> ^U or F: (&self, ^A, ^T) -> ^A, it is a structural invocation requirement on the concrete F, not an erased callable type identity. Concrete closure environments remain monomorphized and inline; satisfying the bound performs no implicit erasure, allocation, or dynamic dispatch. A parameter such as folder: &F is then an ordinary call-scoped exclusive loan of that concrete callable, not another callable type syntax.
  • The environment marker is type metadata and does not enter source call-site arity: a value of (&self, Event) -> void is called as f(event). An outer role prefix binds the complete arrow type, so an owned mutating callback is ^(&self, Event) -> void; grouping remains available in larger type expressions.
  • call is an ordinary identifier. It has no keyword, contextual type, legacy grammar, or compatibility meaning; call(...) is parsed as an ordinary call expression where expressions are legal and is rejected as old callable type syntax in a type position.
  • A callable capability suffix is absent, where Send, where Sync, or canonical where Send + Sync. Duplicate or unknown qualifiers and where Sync + Send are hard errors. The suffix is static type identity, not a runtime flag. Unqualified callable erasure carries neither capability.
  • The future-capability suffix is semantically accepted only on the canonical sealed Future<T> type. Plain Future is neither Send nor Sync; sole where Send is a distinct static identity. Future<T> where Sync, Future<T> where Send + Sync, duplicate/unknown qualifiers, and a qualifier on Generator or another ordinary type are hard errors. A region precedes the suffix, for example ^Future<void> in L where Send.
  • A Future qualifier only weakens to unqualified. Aliases, fields, containers, channel elements, generic substitutions, and CFG joins preserve it; no cast, inferred frame inspection, or runtime witness can strengthen or recover it.
  • A trailing where qualifies the outer completed type. When an erased callable returns a qualified Future, group the result: () -> (Future<T> where Send). Qualifying both identities is written () -> (Future<T> where Send) where Send; the inner suffix belongs to Future and the outer suffix belongs to callable.
  • A closure literal may start with an explicit capture list: x copies a Copy binding, <-x moves owned storage, x in _ stores a shared loan, and &x in _ stores an exclusive loan. Using an outer local not named there is a hard error.
  • (a, b) => expr is an arrow lambda when the parenthesized list contains bare identifiers and is followed by =>. (a: T, b: U) => expr uses explicit parameter types; arrow lambda parameters must be either all bare or all typed.
  • [capture] (a: T) -> U { ... } is a closure literal with an explicit block signature. A parenthesized arrow in type position is an erased callable type; a closure literal remains an expression with parameter names, optional captures, and a body.
  • { ... } in expression position is a struct literal. Conditions for if, while, for, with, and match suppress a following bare struct literal so the next { starts the statement body; parenthesize the expression when a struct literal is needed there.
  • .Variant and .ErrorSet.Variant are contextual member expressions. Their accepted meaning is decided by enum, error, match, catch, and overload context.
  • A contextual member written immediately after return must be separated from the keyword by whitespace: write return .Variant;, not return.Variant;. Do not attach a thrown error path to throw: write throw ErrorSet.Variant;, not throw.ErrorSet.Variant;.

Inline Assembly

EBNF
asm-expr           ::= "asm" "volatile"? "(" string-literal asm-sections? ")"
asm-sections       ::= ":" asm-operands? (":" asm-operands?
                       (":" asm-clobbers?)?)?
asm-operands       ::= asm-operand ("," asm-operand)*
asm-operand        ::= asm-name? string-literal "(" expression ")"
asm-name           ::= "[" identifier "]"
asm-clobbers       ::= string-literal ("," string-literal)*

Formatting And Lint Policy

The grammar intentionally accepts a few context-sensitive forms. The formatter owns their canonical presentation. Style-only differences are not compile errors, and Zynx does not require a separate linter diagnostic for them. A real syntax or semantic error, such as a required semicolon that is missing from a statement form that needs one, is still reported.

Formatter policy for high-context syntax:

  • Statement-form match and statement-form block catch accept an optional trailing semicolon. The formatter preserves an explicit optional semicolon and does not introduce one when it was omitted.
  • Contextual enum and error literals are written with a space after a keyword that introduces an expression: return .Ok;, throw ErrorSet.Bad;, and catch { .Bad => value }. The formatter must not produce return.Ok or throw.Error.
  • Block catch uses spaces around catch and formats multi-arm catch blocks vertically with one arm per line.
  • Explicit generic arguments are attached to the callee or type without spaces: map<i32>(value), Box<T>, and Task<T throws(E)>. Where <...> could be visually confused with comparison, prefer explicit generic syntax adjacent to the callee and use parentheses around surrounding comparisons.
  • Atomic shape arguments stay unbraced (Vector<T, N>); compound shape expressions retain braces and normal operator spacing (Vector<T, {N + 1}>, [T; {N << 1}]).
  • Grouped expressions keep their parentheses. Tuple literals use comma spacing: (a + b) is grouped expression syntax, while (a, b) is a tuple.
  • Short lambdas use spaces around =>. Expression-body lambdas may stay on one line; block-body lambdas format the block like other statement blocks.
  • Struct literals are formatted as expressions, not statement bodies. When a struct literal is followed by a statement block, the literal is terminated by its surrounding delimiter or semicolon before the next block begins. Wrapped struct literals place fields on their own indented lines.

Released under the MIT License.