Statements
Source references link to 01c0f52b (main) in the Logos repository.
Statement-level semantics of Logos: statement forms, let bindings and destructuring, assignment, control-flow statements, divergence, and drop/scope behavior. Extracted from the grammar (tools/peg_gen/grammars/logos.peg), Sema (src/compiler/sema_*), and codegen (src/compiler/mlir_gen_stmt.cpp) layers; each rule cites its source evidence.
Statement forms
stmt.kinds.dispatch — Statement forms
A statement is one of: nested-fn, labeled-loop, let-else, let, for, while, loop, return, break, continue, deref-write, if-expr, match, destructure-assign, assign, compound-assign, place-assign, unsafe-block, block, expr ; (expression statement), or a trailing expr (block tail value). A block without a trailing ; yields its final expression as the block value.
Note: Overlaps
stmt.dispatch.kinds(Sema-layer inventory of the same statement forms); the two lists differ in granularity, not substance.
Evidence: tools/peg_gen/grammars/logos.peg#L1839-L1866
Statement dispatch (Sema view)
stmt.dispatch.kinds — statement forms recognized
Statements comprise: let / let-else / let-destructure / let-pattern bindings, nested fn items, assignment (plain / destructuring / compound / place / deref-write / deref-compound), return, if / if-let-chain, labeled loops, while / for / for-each / loop, match, expression-statement, trailing tail-expression, break, continue, unsafe block, and bare block statement. An if-let-chain in statement position is desugared via the expression form and wrapped as a statement-expression.
Note: Overlaps
stmt.kinds.dispatch(grammar-layer inventory); the two lists differ in granularity, not substance.
Evidence: src/compiler/sema_stmt.cpp#L296-L336
Expression statements
stmt.expr.trailing-semicolon — expression statement vs tail expression
An expression in statement position is terminated by ; (EXPR_STMT), whereas a block’s final expression in tail position (TAIL_EXPR) carries no trailing ;.
Evidence: src/compiler/sema_render.cpp#L724-L733
stmt.expr.discarded-rvalue-dropped — discarded statement-expression rvalue runs its destructor
A statement-expression e; that produces a fresh owned move-typed value (a non-place rvalue, e.g. make(p);) is bound to a synthetic local and dropped at the end of the statement. A bare place expression (existing_var;) is not re-dropped, avoiding double-drop against its scope drop.
Evidence: src/compiler/sema_stmt.cpp#L337-L369
Block statements
stmt.block.scoping-and-unsafe — block statement and unsafe block at statement position
A bare brace-delimited block { stmts... } is a valid statement (scoping block), and unsafe { stmts... } wraps a block with the unsafe modifier at statement position; both contain a sequence of statements.
Evidence: src/compiler/sema_render.cpp#L894-L927
stmt.block.shadow-restore-on-exit — { } block restores shadowed bindings on exit
A { } block statement executes unconditionally. On exit, all name→storage bindings that existed BEFORE the block (scope map, struct/tuple/tagged-enum/dyn-trait/raw-dyn shape tags, local-pointer aliases) are restored to their pre-block values, so a name shadowed by a let inside the block reverts to its outer binding after the block. Names newly introduced inside the block are left in place (harmlessly inert, never referenced afterward).
Evidence: src/compiler/mlir_gen_stmt.cpp#L447-L470
stmt.block.dead-code-after-terminator — Dead code after a hard terminator warns
While lowering a block’s statement list, if the previously-lowered statement is a hard terminator (Return/Break/Continue) and the current AST statement is not an annotation node, block-lowering emits a “unreachable code after terminator” warning; the warning fires at most once per block (further statements in the same trailing tail are not re-warned).
Evidence: src/compiler/sema_stmt.cpp#L671; src/compiler/sema_stmt.cpp#L679-L696
stmt.block.scope-drops-at-exit — Block lowering pushes/pops a lexical scope and drops at normal exit
lower_block pushes a new lexical scope on entry and pops it on exit; when the block’s last lowered statement is not Return/Break/Continue, drops for every live local owned by this scope are appended (in declared order) immediately before the block’s normal fall-through/end.
Evidence: src/compiler/sema_stmt.cpp#L664; src/compiler/sema_stmt.cpp#L739-L754
Statement scopes and temporaries
stmt.scope.temp-drop-at-stmt-end — statement-scoped temporaries dropped at end of statement
Fresh owned (droppable) temporaries materialized while lowering a statement live to the end of that statement and have their destructors run there, in REVERSE order of creation (Rust temporary-scope semantics). Place/borrow expressions (VarRef, FieldRead, IndexRead, Deref, TupleIndex, SliceIndex, SlicePtr, AddrOf, AddrOfTemp) are not hoistable temporaries; only rvalue-producing kinds (Call, MethodCall, StructLit, EnumLitData, …) are.
Evidence: src/compiler/sema_stmt.cpp#L230-L294
stmt.scope.return-temps-dropped-before-terminator — temporaries in a return value drop before the return terminator
When return <val> materializes statement-scoped temporaries, the value is bound to a synthetic local while the temporaries live, the temporaries are then dropped, and only afterward does the return terminator execute — so drops precede the terminator rather than being dead code past it.
Evidence: src/compiler/sema_stmt.cpp#L274-L291
Unsafe blocks
stmt.unsafe-block.context — unsafe block establishes an unsafe context for its body
An unsafe { ... } block lowers its body with an unsafe context active for the duration of the block, restoring the prior context afterward; the body is otherwise an ordinary block.
Evidence: src/compiler/sema_stmt.cpp#L643-L651
let bindings
stmt.let.forms — let bindings
let supports: tuple destructure let (a,b) [: T] = e;, let ref x [: T] = e; (sugar for let x = &e;), let mut x [: T] [= e]; (mutable, type-only declaration without init allowed when typed), let x [: T] [= e];, and let PAT = e; (irrefutable full pattern, refutability checked by sema).
Evidence: tools/peg_gen/grammars/logos.peg#L2246-L2275
stmt.let.binding-form — let binding surface form
A let statement has the form let [mut] NAME [: TYPE] = VALUE ;: the mut keyword is present iff the binding is declared mutable, the : TYPE ascription is optional, and an initializer expression VALUE and trailing ; are always present.
Uncertainty: Inferred from the canonical source-rendering; renderer always emits an initializer, suggesting let without
=is not a form handled here.
Evidence: src/compiler/sema_render.cpp#L694-L709
stmt.let.declare-without-init — let without initializer
let v: T; (no initializer) allocates storage sized/typed by the annotated T and leaves it uninitialized; a later assignment writes through that same storage slot.
let v: i64;
Related: stmt.let.fresh-decl-resets-drop-state
Evidence: src/compiler/mlir_gen_stmt.cpp#L1421-L1436
stmt.let.fresh-decl-resets-drop-state — Re-declaration resets init/drop state
A fresh let declaration of a name resets any stale initialized/uninitialized drop-tracking state left by an earlier same-named binding earlier in the same sequential scope (e.g. shadowing a prior let of the same name, or a declaration re-executed on each loop iteration, starts a new drop-elaboration lifecycle for the name).
Related: stmt.let.declare-without-init
Evidence: src/compiler/mlir_gen_stmt.cpp#L1437-L1439
stmt.let.uninit-drop-flag-vs-static — Conditional init tracked by runtime drop flag
For a declare-without-init binding: if every later assignment statically dominates the binding’s uses (single control-flow path to definition), initialization is tracked statically at compile time and drops are placed unconditionally (no runtime state). If assignment is only conditionally reachable (e.g. inside an if/loop), a runtime i8 drop-flag is allocated and initialized to 0 at the declaration site (re-initialized on each entry, so a declaration inside a loop body resets it every iteration); the flag governs both drop-before-replace and scope-exit drop.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1440-L1454
stmt.let.struct-lit-alloca — Struct-literal let binds directly to the literal’s storage
let name = StructName { ... }; binds name directly to the storage allocated for the struct literal (no extra copy); the binding’s runtime struct-type key is taken from the let’s own type annotation when present, else from the literal’s struct name.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1478-L1486
stmt.let.tuple-lit-binds-value — Tuple-literal let binds the literal’s value directly
let name = (a, b, ...); binds name directly to the value produced by tuple-literal codegen; name is tracked as a tuple-place binding (not a scalar).
Evidence: src/compiler/mlir_gen_stmt.cpp#L1511-L1518
stmt.let.tuple-value-spill-if-byvalue — Tuple-typed let spills a by-value aggregate result before binding
let name: (T1, T2, ...) = expr; where expr’s type is a tuple: if the produced value is already a pointer (e.g. a tuple-literal or a variable reference), name binds to it directly; if it is a by-value aggregate (e.g. a function-call return), it is first spilled into a freshly allocated stack slot and name binds to that slot’s pointer.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1520-L1537
stmt.let.closure-binds-pointer — Closure-typed let binds the closure pointer directly
let name: <closure type> = expr; binds name directly to the pointer value produced by closure codegen, tracked as a tuple-like place binding.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1540-L1547
stmt.let.fnptr-scalar-slot — Function-pointer-typed let stores into a dedicated scalar slot
let name: fn(T) -> R = expr; stores the function-pointer value into a dedicated pointer-sized scalar alloca; name is a plain scalar (pointer-typed) binding. A bare function-item reference (the per-instantiation zero-sized value produced by referencing a plain fn) lowers identically to a function pointer at this site — same representation, same binding path.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1549-L1561
stmt.let.slice-value-copy-semantics — Slice/str let bindings are independent Copy values, never aliases
Slice and str values are Copy {ptr,len} pairs. let name: <slice or str type> = expr;: if expr yields a by-value {ptr,len} struct not yet in storage, it is first spilled to a fresh alloca. If expr yields an existing slice/str PLACE pointer (e.g. a variable, or a field/method access returning str/&[T]), a fresh 16-byte {ptr,len} slot is allocated and the value is memcpy’d into it — name never aliases the source’s storage. Consequently a later write through a let mut binding cannot clobber the source place, and a later write to a mutable source cannot leak into an already-bound copy.
let mut r: str = a; r = "x";
Evidence: src/compiler/mlir_gen_stmt.cpp#L1564-L1595
stmt.let.enum-value-copy-semantics — Tagged-enum let bindings copy the payload footprint into fresh storage
For a tagged-enum-typed let name = expr;: whatever value/pointer expr’s codegen returns, the enum’s inline {discriminant,payload} footprint is memcpy’d (sized to the enum’s finalized layout) into a freshly allocated slot, and name binds to that fresh slot — name never aliases the source place (e.g. a payload-extract or a plain variable copy). A by-value aggregate return is stored whole into the fresh slot first; a bare discriminant scalar (e.g. a no-payload variant) is written via the discriminant-store path. Move-only double-free safety across the (no longer live) source is enforced separately by borrow-checking, which marks the source’s drop as skipped.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1597-L1635
stmt.let.copy-rebind-independent — let binding from a place is an independent copy
let name: S = y; for struct-typed S: if y evaluates to an existing struct pointer (a place — e.g. let copy = orig;), a fresh slot is always allocated and the struct’s bytes memcpy’d into it, rather than aliasing y’s storage — so a later mutation through name (for impl Copy structs) never observably mutates orig; for move-only structs the borrow checker separately forbids further use of orig, making the copy redundant but harmless.
Related: stmt.let.array-rebind-copy, stmt.let.slice-value-copy-semantics, stmt.let.enum-value-copy-semantics
Evidence: src/compiler/mlir_gen_stmt.cpp#L1637-L1668
stmt.let.mut-ref-independent-slot — Mutable reference/pointer let bindings get their own slot; immutable ones alias
let mut r: &S | &mut S | *mut S = expr; (a MUTABLE binding of reference/raw-pointer-to-struct type) allocates its own pointer-sized storage slot, distinct from the pointee’s storage, and stores the reference/pointer value into it — so a later reassignment r = &s2; overwrites only r’s own slot and never corrupts the pointee s1’s storage. An IMMUTABLE let r: &S = expr; binding instead binds directly to the referent’s address (no separate slot allocated) since r can never be reassigned.
let mut r = &s1; r = &s2;
Evidence: src/compiler/mlir_gen_stmt.cpp#L1672-L1701; src/compiler/mlir_gen_stmt.cpp#L1803-L1824
stmt.let.array-rebind-copy — Array let-rebind copies whole array
let b: [T; N] = a; where a evaluates to a pointer to an existing array place memcpy’s the array’s whole byte footprint into b’s freshly allocated array slot (a plain pointer store would overwrite only the first machine word of the destination, leaving the remainder of the array uninitialized).
let b: [i32; 4] = a;
Related: stmt.let.copy-rebind-independent
Evidence: src/compiler/mlir_gen_stmt.cpp#L1890-L1908
stmt.let.scalar-coercion — Scalar let coerces RHS to annotated type
A scalar let binding’s initializer value is passed through integer coercion (widen/narrow/sign-adjust) then float coercion to the binding’s declared scalar type before being stored into its slot.
Evidence: src/compiler/mlir_gen_stmt.cpp#L1909-L1911
stmt.let.destructure-bindings-leak — Destructuring let bindings leak into the enclosing scope
A destructuring let (let Pair{a,b} = e, let (a,b) = e) lowers to a synthetic transparent block whose first statement is a compiler temp named with the __dst/__destruct prefix. Such a block is EXEMPTED from the normal end-of-block shadow-restore, so its field bindings — including ones that shadow a same-named binding from an enclosing scope — leak into (persist in) the enclosing scope after the destructuring let.
Evidence: src/compiler/mlir_gen_stmt.cpp#L424-L446
stmt.let.uninit-drop-flag-when-conditional-init — Uninitialized let needs a runtime drop flag iff conditionally assigned
A let mut x: T; declared WITHOUT an initializer requires a runtime drop flag iff x is later assigned at a conditional/loop nesting depth STRICTLY DEEPER than its declaration depth (the initialized/uninitialized state is then not statically known at scope exit). A bare { } block does not increase nesting depth (it runs unconditionally); each of if/while/loop/for/for-each body, each match arm body, and a let-else else-block adds one level of depth. A var assigned only at its declaration depth is drop-flag-free (its init state is static).
Evidence: src/compiler/mlir_gen_stmt.cpp#L310-L353
stmt.let.definite-assignment — Use of possibly-uninitialised binding is an error
A variable declared without an initializer (let mut x: T;) is tracked as currently-uninitialised; reading it before assignment is a hard error. First assignment marks it initialised. At a CFG merge the post-state is uninit if uninit on any non-diverging incoming path; diverging branches (tail return/break/continue/panic) contribute nothing; loops are conservative (body-only assignments do not initialise the outer scope). Closures get their own saved/restored tracker.
Evidence: src/compiler/sema_impl.hpp#L1897-L1906
stmt.let.no-drop-before-replace-uninit — Reassigning a declared-but-uninitialized binding never drops the old value
A variable declared without an initializer (let mut x: T;) is not definitely-initialized at a later reassignment (a conditional path may have left it uninitialized), so reassignment must NOT drop the prior value first (that would drop garbage). A variable declared WITH an initializer is definitely-initialized at every subsequent reassignment (branches don’t de-initialize) and is safe to drop-before-replace.
Evidence: src/compiler/sema_impl.hpp#L1887-L1896
stmt.let.inferred-annotation-deferred — Top-level _ annotation defers to RHS type
A top-level placeholder annotation let x: _ = rhs drops the annotation entirely and adopts the RHS type. A nested _ inside a composite annotation (Vec<_>) is a hole filled from the RHS during inference rather than dropped.
Evidence: src/compiler/sema_stmt.cpp#L1713-L1721; src/compiler/sema_stmt.cpp#L2179-L2183
stmt.let.annotation-type-hints — let annotation supplies a type hint to RHS inference
A non-hole let annotation hints RHS literal/inference: enum/struct annotations with type-args, fn-ptr/closure annotations (so untyped closure params infer), array/slice element types (incl. through &[T]/&mut [T]), and tuple element types (so untyped int literals widen to the annotated element type instead of defaulting).
let f: fn(i64)->i64 = |x| x+1;
let p:(i64,i64) = (7, 2);
Evidence: src/compiler/sema_stmt.cpp#L1723-L1771
stmt.let.ref-binding-sugar — let ref y = x is sugar for let y = &x
A ref binding let ref y = x (or let ref y: T = x) lowers to taking the address of the RHS, giving y type &T.
Evidence: src/compiler/sema_stmt.cpp#L1897-L1931
stmt.let.declare-uninit — let without initializer declares an uninitialised binding
let v: T; / let mut v: T; (annotation, no value) declares the binding with the annotated type and no value; the binding is recorded as declared-uninitialised so a later assignment registers the value without a drop-before-replace, and the variable must be assigned before use. let without value and without annotation is an error.
Evidence: src/compiler/sema_stmt.cpp#L1942-L1962
stmt.let.closure-capture-drop-ownership — A closure-RHS let owns its captures’ drop slots
A let whose RHS is a closure literal owns the drop slots of that closure’s (un-skipped) captures; they are dropped together with the binding, in capture order.
Evidence: src/compiler/sema_stmt.cpp#L2210-L2222
let with irrefutable patterns
stmt.let-pat.struct-shapes-only — let <pattern> = expr accepts only shapes provable irrefutable here
let <pattern> = expr; accepts only pattern shapes this lowering can prove irrefutable: plain struct patterns, tuple-struct patterns (rewritten via synthesized “0”,“1”,... field names), fixed-size array patterns whose element count matches the array length exactly (no rest), and struct-shaped single-variant-enum patterns; any other pattern shape at this position is rejected with a diagnostic directing the user to match/let-else.
Divergence from Rust: B4
Evidence: src/compiler/sema_stmt.cpp#L1079-L1136
stmt.let-pat.struct-name-match — Struct-pattern let requires exact struct-kind and name match
A struct-pattern let S{...} = rhs; requires rhs’s static type to be Struct/ZonedStruct AND the struct’s name to equal the pattern’s name S; a kind or name mismatch is a compile error.
Evidence: src/compiler/sema_stmt.cpp#L1407-L1418
stmt.let-pat.unknown-field — Struct destructuring-let rejects unknown field names
Each field named in a struct destructuring-let pattern must exist on the (possibly generic-substituted) matched struct’s field list; a name absent from the field list is a compile error.
Evidence: src/compiler/sema_stmt.cpp#L1513-L1517
stmt.let-pat.field-binding-forms — Struct destructuring-let field entries: shorthand, rename, nested struct
A struct destructuring-let field entry binds via shorthand (f -> local binding named f) or a plain-identifier rename (f: x -> local binding named x); a nested struct sub-pattern (f: T{...}) recurses, requiring the sub-pattern’s struct name to equal the field’s struct type’s name; any other sub-pattern form in field-value position is rejected as unsupported at this lowering.
Evidence: src/compiler/sema_stmt.cpp#L1499-L1512; src/compiler/sema_stmt.cpp#L1544-L1563
stmt.let-pat.consumes-source — Struct destructuring-let marks both source and spill-temp moved
A struct destructuring-let that moves the matched value marks BOTH the original source place moved (suppressing its own scope-exit drop) AND the temporary holding the spilled value moved (suppressing that temp’s drop too), since ownership of every field has transferred into the individual field bindings.
Evidence: src/compiler/sema_stmt.cpp#L1471-L1474; src/compiler/sema_stmt.cpp#L1567-L1572
stmt.let-pat.union-requires-unsafe — Irrefutable let-pattern over a union requires unsafe + exactly one field
An irrefutable let U{f} = u; pattern over a union type reads memory through the named field (the same hazard as a match arm over a union) and is a compile error unless lowering occurs inside an unsafe block; the pattern must additionally name exactly one field and must not contain a .. rest, each violation reported separately.
Evidence: src/compiler/sema_stmt.cpp#L1419-L1466
stmt.let-pat.array-fixed-no-rest — let [p0,p1,...] = arr requires exact fixed-length match, no rest
let [p0, p1, ...] = arr; is treated as irrefutable, and thus legal at let, only when arr’s static type is a fixed-size Array and the pattern’s element count equals the array length exactly; a .. rest in this position is a compile error (refutable-shape restriction), and an element-count mismatch is a compile error. Each element position must bind a plain identifier or _ to skip — any other element-pattern shape is a compile error.
Divergence from Rust: B4
Evidence: src/compiler/sema_stmt.cpp#L1120-L1136; src/compiler/sema_stmt.cpp#L1238-L1269; src/compiler/sema_stmt.cpp#L1286-L1317
stmt.let-pat.tuple-struct-rest — let Foo(p0,p1,...) = rhs: single rest, arity, position mapping
let Foo(p0, p1, ...) = rhs; over a tuple struct requires rhs’s type to be exactly Struct Foo; at most one .. rest is allowed among the positional bindings, the named-binding count must not exceed the struct’s field arity, and — mirroring match-arm tuple-struct patterns — names before the rest map to low field positions while names after map to the trailing positions. Each position must bind a plain identifier or _ to skip; any other pattern shape at a position is a compile error, as is a final field-count mismatch after rest expansion.
Evidence: src/compiler/sema_stmt.cpp#L1322-L1406
stmt.let-pat.single-variant-enum-struct — let E::V{...} = rhs is irrefutable only for a single-variant struct-shaped enum
let E::V{f1, f2, ...} = rhs; is treated as irrefutable, and thus legal at let, only when enum E has exactly one variant and that variant V is struct-shaped; it lowers to one synthetic match with a single irrefutable arm per requested binding, each projecting one payload field by position (other payload positions bound to _). Each pattern field must name an existing payload field of V (else compile error) and bind via shorthand or a plain-identifier rename — any other field-value pattern form is a compile error.
Evidence: src/compiler/sema_stmt.cpp#L1086-L1119; src/compiler/sema_stmt.cpp#L1137-L1237
Tuple destructuring let
stmt.let-destruct.tuple-required — let (...) = rhs requires a tuple-typed rhs
let (p0, p1, ...) = rhs; requires rhs’s static type to have Tuple kind; any other kind is a compile error (“right-hand side must be a tuple, got <T>”) and lowering degrades to a bare expression statement.
Evidence: src/compiler/sema_stmt.cpp#L758-L767
stmt.let-destruct.rest-and-arity — Tuple destructuring-let: single .. rest, arity checks, position mapping
In a tuple destructuring-let binding list: at most one .. rest element is allowed (else error); without a rest the binding-list length must equal the tuple arity exactly (else “expected N bindings, got M”); with a rest, the named-binding count must not exceed the arity (else “N bindings exceed tuple arity M”). Names before the rest bind positions 0.. in order; names after the rest bind the trailing arity - trailing_count .. positions, so the rest absorbs the unmatched middle.
Evidence: src/compiler/sema_stmt.cpp#L800-L820; src/compiler/sema_stmt.cpp#L832-L846
stmt.let-destruct.nested-tuple — Tuple destructuring-let binds nested tuple sub-patterns recursively
A tuple destructuring-let binding-list element may itself be a nested tuple binding list (PAT_TUPLE with NAMES), recursively bound against that position’s tuple-typed element, closing let (a, (b, c)) = ...; (and deeper nesting) over arbitrary depth.
Evidence: src/compiler/sema_stmt.cpp#L790-L793; src/compiler/sema_stmt.cpp#L850-L852
stmt.let-destruct.binding-uniqueness — Tuple destructuring-let binding names must be pairwise distinct
All leaf binding names introduced across an entire (possibly nested) tuple destructuring-let must be pairwise distinct across the whole pattern; a repeated name is a compile error in the let (...) destructure binding context.
Evidence: src/compiler/sema_stmt.cpp#L793; src/compiler/sema_stmt.cpp#L855; src/compiler/sema_stmt.cpp#L869-L872
stmt.let-destruct.move-on-bind — Tuple destructuring-let marks each consumed source place moved
Destructuring a move-typed source marks the source place moved at every level it is consumed: the original rhs expression when spilled into the top temporary, each nested level’s source place when spilled into its own temporary, and each leaf element’s source expression when bound to a name — so no level’s temporary double-frees a value now owned by a deeper binding. The move-marking helper self-gates to VarRef/FieldRead/TupleIndex places, so a tuple-literal rhs is a no-op.
Evidence: src/compiler/sema_stmt.cpp#L771-L778; src/compiler/sema_stmt.cpp#L821-L826; src/compiler/sema_stmt.cpp#L856-L859
let-else
stmt.let-else.form — let-else statement
let PAT = expr else { block }; binds a refutable pattern; on match failure the else block runs (which must diverge).
Evidence: tools/peg_gen/grammars/logos.peg#L2242-L2244
stmt.let-else.refutable-binding-diverging-else — let-else binds on match, else block must diverge
let Pat = expr else { block }; tests Pat against expr: on match the pattern’s bindings enter the enclosing scope and control falls through; on failure the else block runs and must diverge (terminate with return/unreachable). A named-wildcard pattern binds the value directly with no test; a unit variant tests the discriminant only; a data-carrying variant tests the discriminant and extracts payload bindings.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4763-L4784
stmt.let-else.scrutinee-tested-then-bind-or-diverge — let-else evaluates scrutinee, tests pattern, binds on match else runs diverging block
let PAT = EXPR else { BLOCK } evaluates EXPR once, tests it against PAT; on match the pattern’s bindings enter the enclosing scope and control continues; on mismatch BLOCK runs. BLOCK must diverge: if it does not terminate, control is unreachable.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4812-L4813; src/compiler/mlir_gen_stmt.cpp#L4955-L4962; src/compiler/mlir_gen_stmt.cpp#L5027-L5028
stmt.let-else.wildcard-irrefutable — Wildcard/binding let-else pattern always matches
When PAT is a wildcard or a bare binding (not _), the pattern is irrefutable: it always matches and binds the scrutinee value; the else block is unreachable.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4816-L4837
stmt.let-else.enum-pattern-refutable — Enum variant pattern in let-else is refutable on discriminant
For an enum scrutinee, a Variant/VariantData pattern matches iff the scrutinee’s discriminant equals the pattern variant’s discriminant; otherwise the else block runs. A C-like (all-nullary) enum scrutinee is its own discriminant value.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4859-L4892; src/compiler/mlir_gen_stmt.cpp#L4902-L4920; src/compiler/mlir_gen_stmt.cpp#L4877-L4885
stmt.let-else.or-pattern-disjunction — Or-pattern in let-else matches any alternative
let A(x) | B(x) = v else … matches iff the scrutinee discriminant equals any alternative’s discriminant (logical OR of per-alt tests). All alternatives must bind the same names at the same payload layout; bindings are extracted using the first alternative’s payload.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4793-L4809; src/compiler/mlir_gen_stmt.cpp#L4906-L4913
stmt.let-else.literal-pattern-refutable — Literal/bool/range pattern in let-else is refutable
For a non-enum integer scrutinee, an Int or Bool pattern matches iff the scrutinee equals the literal; a Range pattern (lo..=hi) matches iff lo <= scrut <= hi (signed). On mismatch the else block runs.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4921-L4950
stmt.let-else.tuple-pattern-irrefutable — Tuple pattern in let-else is irrefutable
A tuple pattern in let-else is always irrefutable: it unconditionally matches and binds each non-_ element field to the corresponding scrutinee tuple element.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4925-L4926; src/compiler/mlir_gen_stmt.cpp#L4969-L4991
stmt.let-else.match-ergonomics-autoderef — let-else auto-derefs reference scrutinee for enum patterns
When the scrutinee has type &Enum, &mut Enum, or *Enum, the enum variant pattern is tested against the referenced enum (match-ergonomics auto-deref), so let Some(v) = &opt else … behaves like a by-value match.
Evidence: src/compiler/mlir_gen_stmt.cpp#L4845-L4858
stmt.let-else.inner-refutable-guard — Refutable sub-patterns in let-else add value guards
A refutable inner sub-pattern (e.g. let Some(1) = … else) lowers to a guard test on the bound payload after the variant discriminant matches; if any guard fails the else block runs. The match succeeds only when the discriminant test and all guards hold.
Evidence: src/compiler/mlir_gen_stmt.cpp#L5005-L5024
stmt.let-else.diverging-else — let-else else-block must diverge
In let PAT = EXPR else { BLOCK }; the else BLOCK must unconditionally diverge (end in return / break / continue / panic / loop {}); a non-diverging else-block is a compile error.
let Some(x) = opt else { return; };
Evidence: src/compiler/sema_stmt.cpp#L1604-L1614
stmt.let-else.bindings-in-outer-scope — let-else pattern bindings escape to the enclosing scope
Bindings introduced by the let-else pattern are defined in the enclosing (outer) scope and remain visible after the statement; the else-block is lowered in a separate nested scope. Each binding takes its type from the matched pattern position. Bindings named _ are not introduced.
Evidence: src/compiler/sema_stmt.cpp#L1604-L1662; src/compiler/sema_stmt.cpp#L1630-L1633; src/compiler/sema_stmt.cpp#L1648-L1649
Let chains
stmt.let-chain.nested-if-let-desugar — if let chain desugars to nested if/if-let with duplicated else
An if/if let chain (segments joined by &&, mixing plain conditions and let PAT = EXPR) desugars to nested if/if let LIR, one nesting level per segment, matching rustc’s classic chained-let expansion. The chain’s else branch (if present) is duplicated at every fall-through nesting level, so side effects in else execute once per short-circuit exit rather than being deduplicated — an accepted consequence of the simple expansion.
Evidence: src/compiler/sema_impl.hpp#L4124-L4130
Assignment
stmt.assign.place-forms — assignment place forms
Assignment statements take the forms: NAME = VALUE ;, NAME OP VALUE ; (compound assign), *NAME = VALUE ; (deref write), *NAME OP VALUE ; (deref compound assign), RECEIVER.FIELD = VALUE ; (field write), and RECEIVER.PATH... = VALUE ; (chained field write through a dot-separated path).
Evidence: src/compiler/sema_render.cpp#L739-L885
stmt.assign.destructuring-into-places — Destructuring assignment into existing places
Destructuring assignment (a,b)=e / [a,b]=e / S{a,b}=e writes into EXISTING places (not new bindings), desugared to let tmp = rhs; followed by per-place assignments.
Divergence from Rust: RFC 2909 (Rust-conformant).
Note: Overlaps
stmt.assign.destructureand thedestructure-assigngroup; all ids preserved.
Evidence: tools/peg_gen/grammars/logos.peg#L311
stmt.assign.simple — Simple variable assignment
name = expr; assigns to a simple variable place.
Evidence: tools/peg_gen/grammars/logos.peg#L2292-L2293
stmt.assign.place — General place assignment
PLACE = expr; where PLACE is an arbitrary postfix-chain lvalue (chained index a[i][j], deref+tuple-index (*p).0, deeper mixes); sema computes the address and emits a deref-write. Tried after the specialized single/two-level write forms and after bare-variable assignment.
Evidence: tools/peg_gen/grammars/logos.peg#L2295-L2304
stmt.assign.destructure — Destructuring assignment into existing places
Tuple (a, b) = e;, array [a, b] = e;, and struct S { f, .. } = e; destructuring assignment writes into existing places (RFC 2909). Parsed before expr-statements so a parenthesised/bracketed LHS followed by = is recognized.
Note: Overlaps
stmt.assign.destructuring-into-placesand thedestructure-assigngroup; all ids preserved.
Evidence: tools/peg_gen/grammars/logos.peg#L2306-L2315
stmt.assign.compound-place — Compound assignment over any place
PLACE op= expr applies a compound assignment over an arbitrary place: a bare variable takes the simple-var path; any other place desugars to place = place op rhs (or an *Assign trait-method call). Bare-deref *p op= v is handled separately (it is not an atom).
Evidence: tools/peg_gen/grammars/logos.peg#L2317-L2323
stmt.assign.eval-rhs-before-drop-old — Assignment order: eval RHS, drop old, then store
For x = e where x is a live (definitely-initialized) place of a droppable type: e is evaluated first, then the OLD value of x is dropped (running its full destructor: user Drop impl + owned children), then the new value is stored. This makes x = f(x) read the pre-assignment x safely.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2000-L2003; src/compiler/mlir_gen_stmt.cpp#L2049-L2051; src/compiler/mlir_gen_stmt.cpp#L2153
stmt.assign.conditional-drop-flag — Reassignment of a conditionally-initialized place drops via a runtime flag
If a place’s liveness depends on control flow reaching it along some paths but not others (e.g. if c { x = a; } x = b;), the compiler maintains a per-place runtime 1-byte init flag; on x = b the old value is dropped only if the flag is nonzero at runtime, and the flag is then set to mark the place live for the new value.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2015-L2039
stmt.assign.first-write-skips-drop — First (dominating) assignment to a statically-uninitialized place skips the drop
For a place whose initialization state is known statically (not flag-tracked), the first assignment reached on the dominating path is known to overwrite indeterminate storage and does not run a drop; every subsequent assignment to that place unconditionally drops the prior (live) value first.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2040-L2048
stmt.assign.undefined-var — Assignment to undefined variable is an error
x = e where x is not bound is rejected: “assignment to undefined variable”.
Evidence: src/compiler/sema_stmt.cpp#L2538-L2546
stmt.assign.static-mut-unsafe — Writing a mutable static requires unsafe
Writing to a module-level static mut requires an enclosing unsafe block: outside unsafe it is rejected (Rust items.static.mut.safety). A local binding (or type parameter) shadowing the static’s name reclassifies the write as a normal local assignment, suppressing the static-mut gate.
Evidence: src/compiler/sema_stmt.cpp#L2547-L2566
stmt.assign.immutable-var — Assignment to immutable variable is an error
Assigning to a non-mut variable is rejected (“assignment to immutable variable”), except the single deferred-initialization write to a let x: T; declared without initializer.
Evidence: src/compiler/sema_stmt.cpp#L2567-L2575
stmt.assign.deferred-init-once — Deferred initialization permits one write without mut
A non-mut local declared without an initializer (let x: T;) may be assigned exactly once; that first assignment initializes it. A second assignment to the same non-mut local is rejected.
Evidence: src/compiler/sema_stmt.cpp#L2567-L2575; src/compiler/sema_stmt.cpp#L2756-L2762
stmt.assign.enum-lit-hint-retype — Assignment LHS pins enum-literal type
When the LHS variable has enum type, the RHS is lowered with that enum as the expected-type hint; an incompletely-typed generic enum literal RHS (no type-args, or any Error type-arg) is retyped to the LHS’s concrete enum spec, provided the LHS is fully concrete and every known (non-error) literal type-arg already matches the LHS at its position (arity must match). A genuine type-arg mismatch is left for the compatibility check to reject.
Evidence: src/compiler/sema_stmt.cpp#L2585-L2638
stmt.assign.type-mismatch — Assignment RHS type-compatibility
x = e requires typeof(e) compatible with typeof(x); otherwise “assignment: type mismatch — expected T, got U”. A #[rel_ptr] ↔ *T relation is also accepted.
Evidence: src/compiler/sema_stmt.cpp#L2639-L2646
stmt.assign.int-widen — Implicit integer widening on assignment
On assignment to an integer variable, a non-literal non-enum integer RHS of a narrower integer kind that can widen safely to the LHS kind is implicitly widened.
Divergence from Rust: Rust has no implicit integer widening on assignment.
Evidence: src/compiler/sema_stmt.cpp#L2647-L2653
stmt.assign.intlit-fits — Integer-literal assignment must fit target type
An integer-literal RHS (including elements of array/tuple literals, recursively through nested arrays/tuples) must fit in the target’s (element’s) integer type; an out-of-range value is rejected: “value V does not fit in T”.
Evidence: src/compiler/sema_stmt.cpp#L2654-L2722
stmt.assign.drop-before-replace — Assignment drops the old value before overwrite
Assigning to a variable that currently holds a live droppable value runs that value’s destructor before the store, and after evaluating the RHS (so x = f(x) is sound). The drop is suppressed when the variable was declared without an initializer (runtime drop-flag governs it instead) or is currently whole- or partially moved-out.
Evidence: src/compiler/sema_stmt.cpp#L2723-L2754
stmt.assign.reassign-revives — Reassignment revives a moved variable
Assigning a new value to a variable clears its moved-out state, making it usable again; the RHS source, if a move-type, is marked moved so its scope-exit drop is suppressed.
Evidence: src/compiler/sema_stmt.cpp#L2755-L2765
stmt.assign.static-write-address — Assignment to an unshadowed module static writes through its address
STATIC = v where STATIC names an unshadowed module-level static lowers to a deref-write through the static’s global address (mut-qualified per its static mut declaration), using the canonical place-store conventions (struct memcpy, enum footprint, fat pairs), rather than the local-slot assign path used for ordinary variables.
Evidence: src/compiler/sema_stmt.cpp#L2766-L2775
Compound assignment
stmt.compound-assign.deref-desugar — *p op= v desugars to *p = *p op v
A deref-compound assignment *p op= v desugars to *p = (*p op v), reading the pointee, applying the binary operator, and writing the result back.
Evidence: src/compiler/sema_stmt.cpp#L516-L568
stmt.compound-assign.deref-mut-dispatch — compound deref-assign on a DerefMut struct dispatches deref_mut
When the left side of *w op= v is a struct (or zoned struct) implementing DerefMut<T>, the operation desugars through w.deref_mut() (yielding &mut T): *(w.deref_mut()) = *(w.deref_mut()) op v.
Evidence: src/compiler/sema_stmt.cpp#L529-L552
Destructuring assignment
stmt.destructure-assign.tuple-array-struct — Destructuring assignment supports tuple/array/struct rhs shapes
Destructuring assignment into existing places selects its rhs-shape requirement from the parsed op discriminant: tuple form ((a,b) = e, requires rhs Tuple), array form ([a,b] = e, requires rhs Array), struct form (S{x:a,y} = e, requires rhs Struct or ZonedStruct); a shape mismatch is a compile error (skipped when rhs is already Error-kind). The rhs is spilled once into a synthetic temporary, and each place is assigned an accessor read off that temporary (tuple-index / index-read / field-read respectively).
Evidence: src/compiler/sema_stmt.cpp#L879-L897; src/compiler/sema_stmt.cpp#L924-L929; src/compiler/sema_stmt.cpp#L1038-L1045
stmt.destructure-assign.place-checks — Destructuring-assignment places must be existing mutable locals
Each destructuring-assignment place is either _/empty (discarded — its accessor is still evaluated as a statement for side effects) or an existing variable name; assigning to an undefined name or to a non-mut variable is a compile error. A successful assignment clears the target from the currently-uninitialized set exactly as a scalar assignment would, even though this lowering path bypasses the ordinary assignment entry point.
Evidence: src/compiler/sema_stmt.cpp#L905-L920
stmt.destructure-assign.rest-and-redundant-parens — Destructuring assignment: redundant-paren unwrap + rest/arity rules
In a tuple/array destructuring-assignment place-list: (a) a place-list of exactly one nested-tuple place, when the source arity is not 1, is treated as redundant parens and the inner list is bound directly (((a,b)) = e is equivalent to (a,b) = e); (b) at most one .. rest place is allowed; without one the place-count must equal the source arity, with one the named-place count must not exceed it, and the rest absorbs the unmatched middle exactly as for destructuring-let.
Evidence: src/compiler/sema_stmt.cpp#L968-L998; src/compiler/sema_stmt.cpp#L1007-L1013
Dereference writes
stmt.deref-write.pointer-or-mutref — deref-write/compound left side must be pointer or mutable reference
The left side of *p = v (and *p op= v) must be a pointer or a mutable reference; otherwise it is an error.
Evidence: src/compiler/sema_stmt.cpp#L553-L557; src/compiler/sema_stmt.cpp#L629-L631
stmt.deref-write.raw-ptr-unsafe — writing through a raw pointer requires unsafe
Writing through &mut T is safe; writing through a raw pointer (*mut/*const) requires an unsafe context. Outside unsafe, a raw-pointer deref-write/compound is an error.
Evidence: src/compiler/sema_stmt.cpp#L558-L560; src/compiler/sema_stmt.cpp#L625-L628
stmt.deref-write.const-ptr-readonly — cannot write through a *const pointer
A *const T pointer is read-only; writing through it is an error — only *mut T or &mut T may be written through.
Evidence: src/compiler/sema_stmt.cpp#L561-L562; src/compiler/sema_stmt.cpp#L632-L634
stmt.deref-write.user-deref-mut — *x = v on a DerefMut struct dispatches deref_mut
When *x = v is applied to a struct x implementing DerefMut<T>, it dispatches x.deref_mut() (returning &mut T) and writes v through the resulting reference; this requires x to be a mutable binding for &mut self materialization.
Evidence: src/compiler/sema_stmt.cpp#L597-L624
stmt.deref-write.variance-invariant — deref-write value must invariantly match the pointee type
In *ptr = val the value’s type must invariant-match (strict, fn-scope-fixed lifetimes) the pointee type of the pointer/reference.
Evidence: src/compiler/sema_stmt.cpp#L635-L639
stmt.deref-write.rhs-type-hint — deref-write RHS inferred against the pointee type
The RHS of *p = v is inferred with an enum/struct type hint taken from the pointee type when the pointee is a parameterized enum or struct, so a bare None resolves to Option<T> matching the slot rather than a discriminant-only constant.
Evidence: src/compiler/sema_stmt.cpp#L575-L595
if statements
stmt.if.diverging-cond-truncates — a diverging if-condition makes the if body unreachable
If evaluating an if condition diverges (already terminates control flow, e.g. if (return x) {}), no part of the if/else/merge is reachable and the statement produces no further code.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2162-L2168
stmt.if.both-branches-diverge-no-merge — if where both branches diverge has no fall-through
An if/else where neither branch falls through (both diverge/terminate) has no merge point; control after the if is unreachable.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2180-L2195
stmt.if.cond-must-be-bool — if condition must be bool
A non-pattern if condition must have type bool; Error and Never types are accepted (Never permits if return x {}), any other type is a compile error.
Evidence: src/compiler/sema_stmt.cpp#L5955-L5961
stmt.if.move-merge-by-branch — Per-branch move state merges by union over non-diverging branches
Across an if’s branches each branch is analyzed from the pre-if move state; the post-if moved set is the union of moves from all non-diverging branches (a branch ending in return/break/continue diverges and contributes nothing). A missing then/else branch behaves as a non-diverging fall-through preserving the pre-if state.
Evidence: src/compiler/sema_stmt.cpp#L5966-L6038
stmt.if.definite-assignment-merge — Definite-assignment merge across if branches
A variable is uninitialized at the if’s merge point iff it is uninitialized on ANY incoming non-diverging branch path (union of currently-uninit sets over non-diverging branches); diverging branches contribute nothing to the merge.
Evidence: src/compiler/sema_stmt.cpp#L5980-L6040
stmt.if.no-trailing-semi — if at statement position needs no trailing semicolon
An if used in statement position requires no trailing ; because it is a brace-bounded expression.
Evidence: src/compiler/sema_render.cpp#L887-L892
if let
stmt.if-let.desugar-to-match — if-let desugars to a two-arm match
if let P = e { THEN } else { ELSE } lowers to match e { P => THEN, _ => ELSE }, with nested-payload destructures emitted before THEN so their bindings are in scope.
Evidence: src/compiler/sema_stmt.cpp#L5843-L5951
stmt.if-let.let-chain-trailing-cond — let-chain trailing condition becomes a match-arm guard
if let P = e && <cond> { THEN } else { ELSE } desugars to match e { P if <cond> => THEN, _ => ELSE }: the chain condition becomes an arm guard, sees the pattern’s bindings, must be bool (else Error accepted), and is conjoined AFTER the pattern’s own refutable guards.
Evidence: src/compiler/sema_stmt.cpp#L5872-L5903; src/compiler/sema_stmt.cpp#L5938-L5945
stmt.if-let.guard-no-nested-variant-binding — let-chain condition cannot reference nested enum-variant bindings
A let-chain trailing condition may reference nested tuple/struct payload bindings (re-extracted as a guard prologue) but may NOT reference bindings from a nested enum-variant payload pattern; doing so is a compile error.
Evidence: src/compiler/sema_stmt.cpp#L5885-L5902
stmt.if-let.refutable-inner-guards — Nested refutable payload predicates gate the then-arm
Refutable inner patterns (nested variant/literal payload predicates) of an if-let pattern are conjoined into the then-arm guard; a predicate failure falls through to the wildcard else-arm.
Evidence: src/compiler/sema_stmt.cpp#L5928-L5937
match statements
stmt.match.scrutinee-form — Match statement
match SCRUT { ARM* } matches a scrutinee against arms. A bare-identifier scrutinee is parsed specially (as a var-ref) so match e { ... } does not mis-parse e { as a struct literal; complex scrutinee expressions fall through to the general expr form.
Evidence: tools/peg_gen/grammars/logos.peg#L1918-L1928
stmt.match.arm — Match arm with optional guard
A match arm is PAT [if GUARD] => BODY, where BODY is a block, an expression, or a statement, with an optional trailing comma. The optional if GUARD is a guard expression gating the arm.
Evidence: tools/peg_gen/grammars/logos.peg#L1930-L1941
loop
stmt.loop.forms — loop and labeled loop
An infinite loop is loop BLOCK; a labeled loop is 'LABEL: BLOCK where labels use a leading single-quote sigil.
Evidence: src/compiler/sema_render.cpp#L792-L804
stmt.loop.infinite — loop block
loop { ... } is an unconditional loop.
Evidence: tools/peg_gen/grammars/logos.peg#L1888-L1894
stmt.loop.labeled — Labeled loop
'label: for/while/loop { ... } attaches a lifetime-syntax label to a loop, targetable by break 'label / continue 'label.
Evidence: tools/peg_gen/grammars/logos.peg#L1891-L1902
stmt.loop.break-value-binding — loop expression value is supplied by break
An infinite loop { ... break e; ... } evaluates to the value e of the break whose target is that loop; the break value is stored into the loop’s result slot. break without a value or in a loop with no result type produces no loop value.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2349-L2371; src/compiler/mlir_gen_stmt.cpp#L2406-L2412
stmt.loop.never-breaks-is-divergent — a loop that never breaks is divergent
An infinite loop body that contains no reachable break targeting it never exits; control after the loop is unreachable (the loop has type !).
Evidence: src/compiler/mlir_gen_stmt.cpp#L2382-L2388
stmt.loop.conservative-init — Loop body may run zero times for init analysis
A loop { ... } body is treated as possibly executing zero times: any variable that becomes definitely-initialized only inside the loop body remains uninitialized in the enclosing scope after the loop (the pre-loop uninit set is snapshotted and restored once the body is lowered).
Evidence: src/compiler/sema_stmt.cpp#L6877-L6894
stmt.loop.diverges-never — Loop with no reachable break diverges (type !)
A loop whose body contains no break reaching the loop diverges; in expression position its type is ! (never). A loop is non-diverging iff some break targeting it carries a value (giving a result type) or breaks without value.
Evidence: src/compiler/sema_stmt.cpp#L6887-L6889; src/compiler/sema_stmt.cpp#L6895-L6897
stmt.loop.break-value-type — Loop expression value comes from break values
If any break <expr> targets the loop, the loop expression’s type is the unified type recorded on its break-frame, and lowering allocates a break-value slot the loop yields at exit; break without value marks the frame non-diverging but gives no result type.
Evidence: src/compiler/sema_stmt.cpp#L6887-L6889; src/compiler/sema_stmt.cpp#L6901-L6904
stmt.loop.label-scope — Loop label active only within its body
A loop label is bound (pushed onto the active label set) only for the duration of lowering the loop body, and is captured before body lowering so the label of the immediately-enclosing loop is the pending label; nested loops push/pop their labels with their bodies.
Evidence: src/compiler/sema_stmt.cpp#L6871-L6872; src/compiler/sema_stmt.cpp#L6883-L6885; src/compiler/sema_stmt.cpp#L6891
Labeled loops
stmt.labeled-loop.label-binding — labeled loop binds its label to the inner loop
'label: <loop> extracts the label, makes it the pending loop label, and lowers the inner for/while/loop with that label in scope so break/continue can target it.
Evidence: src/compiler/sema_stmt.cpp#L317-L330
while
stmt.while.forms — while and while-let
while cond { } is a conditional loop; while let PAT = e [&& guard] { } is a while-let loop; while LET-CHAIN { } is a while-let chain (≥2 segments starting with let), ordered first so it is not shadowed.
Evidence: tools/peg_gen/grammars/logos.peg#L2277-L2287
stmt.while.cond-or-let — while and while-let
A while statement is either while COND BLOCK (condition form) or while let PAT = VALUE BLOCK (while-let form).
Evidence: src/compiler/sema_render.cpp#L777-L790
stmt.while.condition-bool — while condition must be bool
In while COND { ... }, COND must have type bool (or be an error type); any other type is a type error.
while x < 10 { }
Evidence: src/compiler/sema_stmt.cpp#L6225-L6230
stmt.while.body-not-definitely-assigning — while body does not establish definite assignment
A while/while let loop may execute zero times, so assignments/initializations performed in its body do not count as definitely-initialized in the enclosing scope; the definite-assignment state is restored to its pre-loop value on every exit path.
Evidence: src/compiler/sema_stmt.cpp#L6050-L6058
stmt.while.label-binds-loop — loop label attaches to the while loop itself
A leading loop label 'a: on a while/while let binds to that loop (added to the active-label set for its body) and is consumed before lowering the body, so an unlabeled nested loop inside the body cannot capture it; break 'a / continue 'a inside the body resolve to the labeled loop.
'a: while cond { while inner { break 'a; } }
Evidence: src/compiler/sema_stmt.cpp#L6111-L6116; src/compiler/sema_stmt.cpp#L6170-L6176; src/compiler/sema_stmt.cpp#L6220-L6246
stmt.while.loop-depth-context — while body is in loop context
Statements in a while/while let body execute in loop context (loop depth incremented, a break-frame pushed), so break and continue are permitted there and resolve to this loop.
Evidence: src/compiler/sema_stmt.cpp#L6168-L6177; src/compiler/sema_stmt.cpp#L6233-L6242
stmt.while.line-maps-to-header — while loop maps to its header source line
The emitted while loop is attributed to the source line of the while keyword (the loop header), not the last line of its body, for debug/stepping purposes.
Evidence: src/compiler/sema_stmt.cpp#L6059-L6064; src/compiler/sema_stmt.cpp#L6243-L6247
while let
stmt.while-let.desugar-loop-match — while-let desugars to loop + match
while let PAT = EXPR { BODY } is equivalent to loop { match EXPR { PAT => { BODY }, _ => break } }: the loop continues while EXPR matches PAT (binding PAT each iteration), and terminates the first time it does not.
while let Some(x) = iter.next() { use(x); }
Evidence: src/compiler/sema_stmt.cpp#L6108-L6217
stmt.while-let.refutable-pattern-bindings-scope-body — while-let pattern bindings scope over body
Bindings introduced by the while let pattern are in scope only within the loop body (a fresh scope is pushed for the matched arm and popped after the body).
Evidence: src/compiler/sema_stmt.cpp#L6133-L6185
stmt.while-let.chain-trailing-cond-must-be-bool — while-let trailing chain condition must be bool
In a while let PAT = EXPR && COND chain, the trailing condition COND must have type bool (or error); it is evaluated with the pattern’s bindings in scope and folded into the match-arm guard so the loop continues only when the pattern matches AND COND holds.
while let Some(x) = it.next() && x > 0 { }
Evidence: src/compiler/sema_stmt.cpp#L6138-L6166; src/compiler/sema_stmt.cpp#L6199-L6205
stmt.while-let.chain-cond-no-nested-variant-bindings — while-let chain condition cannot reference nested enum-variant bindings
A while let chain trailing condition may not reference bindings introduced by a nested enum-variant subpattern of the same pattern; such a reference is an error (match in the body instead).
Uncertainty: Stated as a current implementation limitation (‘cannot yet’); likely a temporary divergence rather than an intended language rule.
Evidence: src/compiler/sema_stmt.cpp#L6149-L6155
stmt.while-let.chain-multiseg-desugar — multi-segment while-let chain desugars to nested if-let-else-break
A multi-segment let-chain while let P1 = e1 && (let P2 = e2 | cond) && ... { BODY } desugars to loop { { if let P1 = e1 { { if let P2 = e2 { ... BODY ... } else { break; } } } else { break; } } }, building inside-out so each segment (a let PAT = VALUE or a boolean condition) wraps the running body and falls to break on failure; a chain requires at least 2 segments.
while let Some(a) = x.next() && let Some(b) = y.next() { f(a, b); }
Evidence: src/compiler/sema_stmt.cpp#L6065-L6107
for loops
stmt.for.range-and-iter — for over range and for-each over iterator
A for-over-range loop is for NAME in LHS (..|..=) RHS BLOCK, where ..= denotes an inclusive upper bound and .. an exclusive one; a for-each loop is for NAME in ITER BLOCK.
Evidence: src/compiler/sema_render.cpp#L806-L831
stmt.for.range — For-range loop
for i in lo..hi { } iterates the exclusive integer range; for i in lo..=hi { } iterates the inclusive range.
Evidence: tools/peg_gen/grammars/logos.peg#L1868-L1877
stmt.for.each — For-each loop
for x in iter { } iterates over an iterable. The loop variable may be a simple identifier (fast path) or a full destructuring pattern for (a,b) in v { }, in which case the pattern is bound against each element.
Evidence: tools/peg_gen/grammars/logos.peg#L1878-L1886
stmt.for.range-loop-type-widening — numeric range-for uses the wider of bound types for the induction variable
A range for i in lo..hi / lo..=hi uses an induction variable whose width is the wider of the lo and hi integer types (default i32, widened to i64+ when a bound is wider), so wide bounds are not truncated. Bounds are extended to the loop type with unsigned extension when the bound type is unsigned, otherwise signed.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2250-L2272; src/compiler/mlir_gen_stmt.cpp#L2299-L2303
stmt.for.range-comparison-signedness — range-for comparison and inclusivity follow bound signedness
A range for continues while i < hi (exclusive) or i <= hi (inclusive ..=); the comparison is unsigned when the upper-bound type is unsigned, otherwise signed. The induction variable is incremented by 1 after each body execution.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2304-L2336
for-each iteration
stmt.for-each.slice-yields-element-reference — for-in over a slice yields &T element references
Iterating for x in &[T] (a slice) binds x to a reference into the original buffer (the element address), not a copied value; the slice is iterated over its runtime len field. Mutating through x mutates the original buffer.
Uncertainty: binding-as-reference is asserted as Rust parity in comments; exact mutability of the binding is not fully constrained here.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2474-L2529
break
stmt.break.forms — break
break;, break expr;, break 'label;, and break 'label expr; are all valid; a value and/or a target label are optional. A bare break may also be terminated by ,.
Evidence: tools/peg_gen/grammars/logos.peg#L1904-L1911
stmt.break.label-resolution — Labeled break targets the matching enclosing loop; unlabeled break targets the innermost
break;/break v; with no label transfers control to the innermost enclosing loop. break 'label;/break 'label v; searches the enclosing loop stack from innermost outward for a loop whose label equals 'label and targets that loop.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2549-L2567
stmt.break.label-targets-named-loop — labeled break/continue target the matching enclosing loop
An unlabeled break/continue targets the innermost enclosing loop; a labeled break 'l/continue targets the nearest enclosing loop whose label equals the given label.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2394-L2417
stmt.break.outside-loop — break only inside a loop
A break is an error outside any loop. A labeled break 'l is an error unless 'l is an active in-scope loop label.
Evidence: src/compiler/sema_stmt.cpp#L456-L465
stmt.break.target-resolution — break targets the matching labeled or innermost loop
A labeled break 'l v targets the nearest enclosing loop with label 'l (searched innermost-out); an unlabeled break v targets the innermost loop. The break value attributes to the target frame, so a value breaking to an outer labeled loop is not consumed by an inner loop.
Evidence: src/compiler/sema_stmt.cpp#L466-L502
stmt.break.value-consistency — all breaks of a loop must agree on value presence and type
All breaks targeting the same loop must agree: a loop cannot mix value-carrying and value-less breaks (break v vs break); and all break values must have mutually compatible types, the loop’s break type being the unification of them.
Evidence: src/compiler/sema_stmt.cpp#L483-L501
continue
stmt.continue.forms — continue
continue; and continue 'label; are valid; the target label is optional. A bare continue may also be terminated by ,.
Evidence: tools/peg_gen/grammars/logos.peg#L1913-L1916
stmt.continue.for-increments-first — continue in a counted for runs the increment before the next iteration
In a counted/iterating for, continue branches to the increment step (advance index/induction variable) before re-evaluating the loop condition, rather than skipping straight to the condition.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2319-L2323; src/compiler/mlir_gen_stmt.cpp#L2531-L2536; src/compiler/mlir_gen_stmt.cpp#L2632-L2637
stmt.continue.labeled-target — Labeled continue targets the matching enclosing loop
A labeled continue 'label transfers control to the continuation block of the nearest enclosing loop (searching the loop stack from innermost to outermost) whose label equals 'label. An unlabeled continue, or a label matching no enclosing loop on the stack, falls back to the innermost loop’s continuation block.
Evidence: src/compiler/mlir_gen_stmt.cpp#L403-L414
stmt.continue.outside-loop — continue only inside a loop with in-scope label
A continue is an error outside any loop. A labeled continue 'l is an error unless 'l is an active in-scope loop label.
Evidence: src/compiler/sema_stmt.cpp#L504-L514
break / continue interaction with drops and labels
stmt.break-continue.label-value — break and continue with optional label and value
break ['LABEL] [VALUE] ; may carry an optional loop label (single-quote sigil) and an optional break value; continue ['LABEL] ; may carry an optional loop label.
Evidence: src/compiler/sema_render.cpp#L833-L855
stmt.break-continue.drops-to-loop-boundary — break/continue drop every frame down to and including the loop-body frame
Lowering break/continue inserts drops for every scope frame from the current point down to AND INCLUDING the frame tagged as the enclosing loop’s body boundary, so a break/continue nested inside an if inside the loop body still releases all intervening locals — bypassing the block’s ordinary single-scope end-of-block drop sequence.
Evidence: src/compiler/sema_stmt.cpp#L667-L670; src/compiler/sema_stmt.cpp#L726-L734
return
stmt.return.form — return statement
return [expr]; returns from the enclosing function with an optional value; may be terminated by ; or ,.
Evidence: tools/peg_gen/grammars/logos.peg#L2289-L2290
stmt.return.optional-value — return statement with optional value
A return statement is return [VALUE] ;; the value expression is optional (return without a value when no VALUE is present).
Evidence: src/compiler/sema_render.cpp#L711-L722
stmt.return.fallthrough-void-implicit — Void function body may fall through to an implicit return
A function with no return type (void) whose body reaches the end of its final block without an explicit terminator gets a plain return with no operand inserted implicitly.
Evidence: src/compiler/mlir_gen_fn.cpp#L509-L525
stmt.return.exhaustive-tail-unreachable — Sema-accepted exhaustive non-void tail lowers to unreachable, not a return
For a non-void function whose body falls through to the end of codegen without an explicit terminator (e.g. an exhaustive tuple/struct match with no wildcard arm — a path sema’s reachability analysis has already accepted as complete, but which mlir-gen’s dispatch lowering does not itself prove exhaustive), mlir-gen inserts an unreachable terminator instead of a value-producing return, so the function still verifies structurally; this path is dead code, never executed at runtime.
Related: stmt.return.fallthrough-void-implicit
Evidence: src/compiler/mlir_gen_fn.cpp#L523-L536
stmt.return.never-fn-operandless — return in a -> ! function is operand-less
If the enclosing function’s return type is the never type !, a return e evaluates e for its side effects (it may itself diverge and terminate control flow) but produces a value-less return; the function has a 0-result signature.
fn f() -> ! { return diverging() }
Evidence: src/compiler/mlir_gen_stmt.cpp#L2024-L2034
stmt.return.empty-yields-unit — bare return yields no value
A return with no operand produces a value-less return.
Evidence: src/compiler/mlir_gen_stmt.cpp#L2150-L2155
stmt.return.all-paths-required — Non-void fn body must return on all paths
In a specialization fn body, if the declared return type is neither void nor an error type, every control-flow path through the lowered body must return a value; otherwise it is a compile error “not all paths return a value”.
Evidence: src/compiler/sema_collect.cpp#L4736-L4741
stmt.return.drops-after-value-eval — return evaluates its value before running pending scope drops
Lowering return expr; evaluates/lowers expr first; if any scope drops are pending at that point, the return value is spilled into a fresh temporary __ret_tmp_N, all pending drops are emitted, and the Return is rewritten to return the temporary — guaranteeing expr (which may borrow locals about to be dropped) is fully evaluated before drop glue runs. With no pending drops, the temp-spill step is skipped and drops are appended directly before the original Return.
Evidence: src/compiler/sema_stmt.cpp#L699-L724
stmt.return.value-required — Return-without-value only in unit/never/impl-Trait functions
return; (no value) is rejected in a function whose return type is not unit, error, or impl Trait: “return without value in function returning T”.
Evidence: src/compiler/sema_stmt.cpp#L3019-L3024
stmt.return.type-mismatch — Return value type-compatibility
return e requires typeof(e) compatible with the declared return type, after normalizing associated-type projections via equality bounds (T: Trait<A=V>); otherwise “return type mismatch — expected T, got U”.
Evidence: src/compiler/sema_stmt.cpp#L2859-L2868
stmt.return.impl-trait-infer — impl-Trait return type inferred from first return
When the declared return type is impl Trait, the concrete return type is inferred from the first non-error return expression.
Evidence: src/compiler/sema_stmt.cpp#L2854-L2858
stmt.return.hint-propagation — Return type hints literal/closure inference
The declared return type seeds expected-type hints while lowering the return value: a generic enum/struct return type pins literal type-params; a fn-ptr/closure or wrapped-callable (Box<dyn Fn(..)>) return type infers an untyped closure literal’s params; a (possibly &-wrapped) array/slice return type supplies an array-literal element-type hint.
Evidence: src/compiler/sema_stmt.cpp#L2784-L2817
stmt.return.temp-hoist-before-return — Return value’s hoisted temporaries drop before control transfers
When lowering the return expression hoists droppable statement-temporaries (e.g. a droppable rvalue receiver make().get()), the return value is bound to a synthetic local first; the enclosing statement lowering then emits the temporaries’ drops before the return of that local, so the temporaries are not leaked (they would be, since drops normally emitted after a statement are dead code after a return).
Evidence: src/compiler/sema_stmt.cpp#L3002-L3015
Tail expressions
stmt.tail.implicit-return-nonvoid — non-void tail expression is an implicit return at fn body
At function-body level with a non-void declared return type, a trailing expression (no semicolon) is an implicit return <e>. If its type is Void it is lowered as an expression-statement instead; only a non-void tail becomes an implicit return.
Evidence: src/compiler/sema_stmt.cpp#L371-L450
stmt.tail.closure-inference-return — non-void tail in closure inference body is the implicit return
In a closure body lowered in inference mode (no declared return type), a non-void, non-error tail expression is the closure’s implicit return; a void/error tail is an expression-statement. No return-type compatibility check is applied (the closure has no declared type).
Evidence: src/compiler/sema_stmt.cpp#L390-L398
stmt.tail.enum-hint-from-ret-type — tail enum-literal inference threads the fn return type
When the function’s declared return type is an enum, a tail-position enum literal (Result::Ok(v), Either::L(x)) is inferred against that return type so the enum’s type parameters not constrained by the chosen variant are resolved from the return type rather than inferred as error.
Evidence: src/compiler/sema_stmt.cpp#L402-L419
stmt.tail.return-type-mismatch — tail implicit return type-checked like explicit return
The type of a tail-position implicit return must be compatible with the declared return type, must pass the variance gate, and must satisfy dyn+auto bound checks at coercion — identically to an explicit return. Moving out of a value behind a reference or out of an index in tail-return position is rejected (E0507).
Evidence: src/compiler/sema_stmt.cpp#L427-L447
Divergence analysis
stmt.diverge.return-stmt — return statement always returns
A return statement is a diverging statement: control never falls through to the following statement.
Evidence: src/compiler/sema_stmt.cpp#L28-L29
stmt.diverge.never-returning-call — call to a Never-returning fn diverges
A call expression f(...) (including the macro form panic!(...) which parses as FN_MACRO_CALL) in expression-statement, tail-expression, or let-initializer position is divergent — control never falls through — iff the callee is named panic OR any candidate function with that name has return type ! (Never). panic is recognized by name even without a ! annotation.
Divergence from Rust: A:
panicrecognized as divergent by hardcoded callee name (Logos historically lacked the!type); now generalized to any-> !callee.
Evidence: src/compiler/sema_stmt.cpp#L34-L53; src/compiler/sema_stmt.cpp#L208-L218
stmt.diverge.block-value — block/if/match in expr-stmt position diverges if body does
An expression-statement or tail-expression whose value is a BLOCK, IF, or MATCH diverges iff that nested construct always returns; a value of RETURN/BREAK/CONTINUE expression form always diverges.
Evidence: src/compiler/sema_stmt.cpp#L54-L64
stmt.diverge.tail-expr-context — tail expression counts as implicit return only at fn-body context
A trailing expression (TAIL_EXPR, no semicolon) is treated as an implicit return — and thus a diverging tail — only when it is in function-body position; in match-arm-body or block-as-expression contexts the same node is the block’s value, not a return.
Evidence: src/compiler/sema_stmt.cpp#L65-L69
stmt.diverge.let-diverging-init — let with diverging initializer never binds
let x = <e>; diverges when its initializer <e> is a RETURN/BREAK/CONTINUE expression, a divergent call, a BLOCK that always returns, or an IF/MATCH that always returns; the binding never occurs.
Evidence: src/compiler/sema_stmt.cpp#L87-L95
stmt.diverge.infinite-loop — loop without break diverges
A loop { ... } statement never falls through to the next statement (it is an infinite loop, diverging unless exited by a non-fallthrough construct).
Evidence: src/compiler/sema_stmt.cpp#L96-L99
stmt.diverge.if-both-branches — if/else diverges iff both branches diverge
An if always returns iff it has an else and both the then-block and the else-branch always return; an if without else never forces a return.
Evidence: src/compiler/sema_stmt.cpp#L100-L109
stmt.diverge.match-all-arms — match diverges iff all arms diverge
A non-empty match always returns iff every arm’s body always returns; an expression arm (pat => expr) provides a value and does not count as diverging; an empty match does not force a return.
Evidence: src/compiler/sema_stmt.cpp#L110-L131
stmt.diverge.block-reaches — block diverges if any statement diverges
A block always returns iff at least one of its statements always returns (a diverging statement makes all following statements unreachable).
Evidence: src/compiler/sema_stmt.cpp#L135-L143
stmt.diverge.break-continue-divert — break/continue divert control flow
break and continue are divergent for loop-body fallthrough analysis: like return, control does not fall through to the following statement. if/else and match propagate diversion when all branches/arms divert.
Evidence: src/compiler/sema_stmt.cpp#L145-L182
Never-type fallback
stmt.fallback.never-only-on-provable-divergence — Never-fallback gated on provably non-returning body
A generic return type-param may fall back to ! only when the callee body provably never returns normally — i.e. the body’s last statement is a divergent call (panic/-> !), a loop, or an expression-statement/tail wrapping a loop. A body ending in return 0; does NOT qualify (that is a normal return, leaving the type-param ambiguous).
Divergence from Rust: A: implements a Rust-2024-style
!-fallback but with a stricter, narrower divergence predicate than fullblock_always_returns.
Evidence: src/compiler/sema_stmt.cpp#L194-L226
Nested fn items
stmt.fn.nested-lifts-to-toplevel — Nested function lifted to a free function
A fn name(params) [-> T] { body } at statement position is lifted to a top-level free function (gensym name); the local name is bound as a fn-ptr let. A nested fn does not capture enclosing locals.
Note: Possible layer conflict: this grammar-layer rule says a nested
fnis lifted to a top-level free function with the local name bound as a fn-ptrlet, whilestmt.nested-fn.let-bound-closure(Sema layer) describes lowering to alet-bound closure with the closure’s inferred type. Both are preserved verbatim; reconcile at freeze.
Evidence: tools/peg_gen/grammars/logos.peg#L303
Nested fn lowering
stmt.nested-fn.let-bound-closure — Statement-position fn desugars to a let-bound closure
A function item at statement position fn inner(params) [-> T] { body } is lowered as an immutable local binding let inner = |params| -> T { body }; the binding’s type is the closure’s inferred type.
Note: Possible layer conflict with
stmt.fn.nested-lifts-to-toplevel(grammar layer: lift to top-level free fn + fn-ptrlet). Both are preserved verbatim; reconcile at freeze.
Evidence: src/compiler/sema_stmt.cpp#L1673-L1691