19. Statements, Blocks, Regions, Frames & Defer
a5d21aff583bfbb6d9db8ef52b842fec80adad1864f5846488ab5bc00e090e24
This chapter specifies the body of every Ultraviolet procedure, method, transition, and block expression: the statements that compose a block, how a block produces a value, the binding forms let and var, local using aliases, assignment and compound assignment, expression statements, deferred cleanup with defer, arena scopes with region, stack-like scopes with frame, control transfer with return / break / continue, and unsafe statements. It corresponds to specification §18 (Statements and Blocks). Region allocation uses new for the current scoped region and Region@Active~>alloc for an explicit target; this chapter shows how both forms are used inside region and frame scopes.
Related material: blocks are also expressions (Expressions chapter, §16.7); patterns used in let / var are covered in the Patterns chapter (§17); loop, if, and case analysis are in the Control-Flow Expressions chapter (§16.7); the key system that mediates shared writes is in the Key System chapter (§19 of the spec); permission semantics (const, unique, shared) are in the Permissions chapter (§10.4).
Terminology note: Ultraviolet does not have loop labels.
breakandcontinuealways target the innermost enclosingloop. There is nobreak 'name/continue 'namesyntax, and the grammar admits no label production anywhere. “Labels” in the colloquial sense are therefore covered here by the single rule that control transfer targets the nearestloop.
Brace note for examples: every
if,loop, andelsebody in this chapter uses an explicit brace-delimitedblock_expr. The grammar (if_tail,loop_expr, §16.7.1) parses these bodies throughParseBlock, which requires an opening{(§18.1.2,Parse-Block). The examples here keep braces on every control-flow body so they parse as written. (See Pitfalls for the relationship to the style guide’s single-statement allowance.)
19.1 Blocks and Block Values (§18.1)
Section titled “19.1 Blocks and Block Values (§18.1)”A block is a brace-delimited sequence of statements followed by an optional trailing tail expression. The block is an expression: its value and type come from the tail.
19.1.1 Grammar
Section titled “19.1.1 Grammar”statement_seq ::= statement* expression?statement ::= binding_stmt | using_local_stmt | assignment_stmt | compound_assign | expr_stmt | defer_stmt | region_stmt | frame_stmt | return_stmt | break_stmt | continue_stmt | unsafe_block | key_block_stmt | comptime_stmtblock_expr ::= "{" statement_seq "}"The consolidated grammar (Appendix B.5) lists the same statement forms:
statement ::= binding_stmt | using_local_stmt | assignment_stmt | compound_assign | expr_stmt | return_stmt | break_stmt | continue_stmt | defer_stmt | region_stmt | frame_stmt | unsafe_block | key_block_stmt | comptime_stmtkey_block_stmt is defined in the Key System chapter (Chapter 19 of the spec); comptime_stmt is defined in the Metaprogramming chapter (§22.1.1). A statement may carry a leading attribute list (Chapter 9); attributes are attached by AttachStmtAttrs during parsing.
19.1.2 Statement terminators
Section titled “19.1.2 Statement terminators”A statement is terminated by a newline or a semicolon. The terminator set and grammar are:
StmtTerm = {Punctuator(";"), Newline}terminator ::= ";" | newlinenewline ::= "\n"Newlines are the default terminator. Use ; only to place several short statements on one line, or where surrounding syntax requires it (the style guide prefers newline termination; see §19.11).
The following statement forms require a terminator (ReqTerm):
let/varbindings (LetStmt,VarStmt)- local
using(UsingLocalStmt) - assignment and compound assignment (
AssignStmt,CompoundAssignStmt) - expression statements (
ExprStmt)
The block-structured statements — defer, region, frame, unsafe, key blocks, and the control-flow expressions that end in a block — do not require a trailing terminator because their closing brace already delimits them. return, break, and continue take an optional terminator (terminator? in §18.9.1).
19.1.3 The block value (tail expression)
Section titled “19.1.3 The block value (tail expression)”A block’s type is determined by how it ends. The spec partitions blocks into exactly three BlockInfo cases:
- Tail value (
BlockInfo-Tail): if the block ends with a tail expressione : T(andeis not a return-shaped tail), the block has typeTand evaluates to the value ofe. - Unit (
BlockInfo-Unit): if there is no tail expression and the last statement is not areturn, the block has type()(unit) and evaluates to(). - Return tail / never (
BlockInfo-ReturnTail): if there is no tail expression and the last statement is areturn, the block has type!(never), because control leaves the block before any value is produced.
A tail expression has no terminator — it is the bare trailing expression. If you place a terminator after the last expression, it becomes an expression statement, the block has no tail, and the block’s value is ().
// Block whose value is the tail expression: type i32, value 30.let total: i32 = { let base: i32 = 10 let bonus: i32 = 20 base + bonus // tail expression — no terminator}
// Block with no tail: its value is unit.let nothing: () = { let scratch: i32 = 99 logScratch(scratch) // expression statement, terminated by newline}The let total block ends in base + bonus with no terminator, so the block’s type is i32. The let nothing block ends in a terminated statement, so its type is ().
19.1.4 Scope, evaluation order, and cleanup
Section titled “19.1.4 Scope, evaluation order, and cleanup”Entering a block pushes a fresh lexical scope (PushScope / BlockEnter); statements execute top to bottom (ExecSeqSigma); the tail expression, if present, is evaluated last. Leaving the block runs scope cleanup (BlockExit → CleanupScope) — this is where defer blocks and value destructors run (see §19.6) — then pops the scope.
If any statement produces a control transfer (Ctrl(κ) — a Return, Break, Continue, Panic, or Abort), the remaining statements and the tail are skipped (ExecSeq-Cons-Ctrl), but scope cleanup still runs on the way out (BlockExit).
A block-prefix statement sequence whose result set has no common type is a block-result join failure (BlockInfo-Res-Err, §18.1.7). Unreachable block results are reported by WarnResultUnreachable.
19.2 Binding Statements: let and var (§18.2)
Section titled “19.2 Binding Statements: let and var (§18.2)”19.2.1 Grammar
Section titled “19.2.1 Grammar”binding_stmt ::= ("let" | "var") pattern (":" type)? binding_op expression terminatorbinding_op ::= "=" | ":="let introduces an immutable binding (MutKind = let). var introduces a mutable binding (MutKind = var). The left side is a pattern (Patterns chapter, §17), so bindings may destructure tuples, records, and enum/modal payloads. An optional : type annotation constrains the binding; without it the type is inferred from the initializer.
19.2.2 The two binding operators: = vs :=
Section titled “19.2.2 The two binding operators: = vs :=”The binding operator chooses the movability of the binding:
Movability ::= mov | immovMovOf("=") = mov // ordinary bindingMovOf(":=") = immov // immovable (owning) binding=produces an ordinary (mov) binding. The initializer’s value is bound; the binding may be moved out of later according to the normal ownership rules.:=produces an immovable (immov) binding: the binding owns its value and may not be moved out of. Attempting to move from an immovable:=binding isE-MEM-3006. Use:=when a value must stay put for the lifetime of its scope — for example a resource whose destructor must run in place and which must not escape.
let frame_count: u32 = 0 // immutable, ordinaryvar cursor: usize = 0 // mutable, ordinarylet device := openDevice(handle) // immutable, immovable: device stays in this scope19.2.3 Typing rules
Section titled “19.2.3 Typing rules”For an annotated binding the initializer is checked against the annotation; for an unannotated binding the type is inferred and the pattern checked against it:
T-LetStmt-Ann: with annotationT_a, checkinit ⇐ T_a, check the pattern againstT_a, requireDistinct(PatNames(pat)), then introduce the names asletbindings viaIntroAll.T-LetStmt-Infer: with no annotation, inferinit ⇒ T_i, solve, check the pattern against the solved type, then introduce the names.T-VarStmt-Ann/T-VarStmt-Inferare identical except the names are introduced asvarbindings viaIntroAllVar.
A binding pattern must introduce distinct names (Distinct(PatNames(pat))); a duplicate name within one pattern is a diagnostic.
The pattern in a let / var binding must be irrefutable. Literal, enum, modal, and range patterns are refutable and are rejected here (Let-Refutable-Pattern-Err); use if ... is or a loop ... in for refutable matching (Control-Flow and Patterns chapters).
A binding whose target type has unique permission and whose initializer is a place expression requires an explicit move (B-LetVar-UniqueNonMove-Err, E-MEM-3007):
let owned: unique Buffer = move source_buffer // explicit move required19.2.4 Worked example
Section titled “19.2.4 Worked example”procedure summarize(samples: [f64]) -> f64 { var running_total: f64 = 0.0 let sample_count: usize = samples.len()
loop sample in samples { running_total += sample }
if sample_count == 0 { return 0.0 }
return running_total / (sample_count as f64)}running_total is var because it is reassigned; sample_count is let because it is fixed after binding.
19.3 Local using Statements (§18.3)
Section titled “19.3 Local using Statements (§18.3)”19.3.1 Grammar
Section titled “19.3.1 Grammar”using_local_stmt ::= "using" identifier "as" identifier terminatorA local using introduces an alias in the current block scope: after using source as alias, the name alias resolves to exactly the same entity as source. It introduces no new storage — alias and source denote the identical Entity, and aliasing an alias resolves through to the original.
19.3.2 Semantics
Section titled “19.3.2 Semantics”- Static:
T-UsingLocalStmtextends the environment through theUsingAliasjudgment (§7.2). An unresolved source name, a reserved alias name, or an alias already bound in scope is a diagnostic (T-UsingLocalStmt-Err; see §7.2Using-Alias-*rules). - Dynamic:
ExecSigma-UsingLocalhas no runtime effect — name resolution is compile-time only. - Lowering:
Lower-Stmt-UsingLocalproducesNoOpIR; the statement is consumed entirely during resolution.
19.3.3 Worked example
Section titled “19.3.3 Worked example”procedure renderPass(frame_graph: FrameGraph) -> RenderReport { using frame_graph as graph
let pass_count: usize = graph.passCount() return RenderReport { passes: pass_count }}Use a local using only where an alias genuinely improves clarity or resolves a real collision. Do not use it to simulate shadowing or to rename for cosmetic reasons (style guide).
19.4 Assignment Statements (§18.4)
Section titled “19.4 Assignment Statements (§18.4)”19.4.1 Grammar
Section titled “19.4.1 Grammar”assignment_stmt ::= place_expr "=" expression terminatorcompound_assign ::= place_expr compound_op expression terminatorcompound_op ::= "+=" | "-=" | "*=" | "/=" | "%="place_expr ::= identifier | postfix_expr "." identifier | postfix_expr "[" expression "]"The target of an assignment must be a place expression (an l-value). The place root is the underlying variable, computed by PlaceRoot:
PlaceRoot(Identifier(x)) = xPlaceRoot(FieldAccess(p, _)) = PlaceRoot(p)PlaceRoot(TupleAccess(p, _)) = PlaceRoot(p)PlaceRoot(IndexAccess(p, _)) = PlaceRoot(p)PlaceRoot(Deref(p)) = PlaceRoot(p)19.4.2 Plain assignment
Section titled “19.4.2 Plain assignment”T-Assign requires: the target is a place (IsPlace(p)), its root variable is var (mutable), and the right-hand expression checks against the place type T_p:
IsPlace(p) PlaceRoot(p) = x MutOf(Γ, x) = `var` p :place T_p e ⇐ T_pDirect mutation of a shared place is valid when the Key System (Key chapter, §19.1.6) can form a valid KeyPath(p) with RequiredMode(p) = Write and no key scope, escape, or conflict rule forbids the access. If no covering write key is already held, the assignment implicitly acquires one through the ordinary access rules. E-TYP-1604 applies only when no valid key-mediated write context can be formed.
19.4.3 Compound assignment
Section titled “19.4.3 Compound assignment”T-CompoundAssign requires a var numeric place: the place type, with permission stripped, must be a primitive numeric type, and the right side must be a subtype of that numeric type:
IsPlace(p) PlaceRoot(p) = x MutOf(Γ, x) = `var`p :place T_p StripPerm(T_p) = TypePrim(t) t ∈ NumericTypes e : T_e T_e <: TypePrim(t)x op= e reads the place once, evaluates e, applies the binary operator, and writes the result back (ExecSigma-CompoundAssign: ReadPlaceSigma(p) ⇒ evaluate e ⇒ BinOp(op, v_p, v_e) ⇒ WritePlaceSigma(p, v)).
19.4.4 Diagnostics
Section titled “19.4.4 Diagnostics”- Target is not a place (
Assign-NotPlace):E-SEM-3131. - Assignment to an immutable
letbinding (Assign-Immutable-Err):E-MOD-2401. - Type mismatch, or compound assignment on a non-numeric place (
Assign-Type-Err):E-SEM-3133. - Assignment / compound-assignment targets with effective
constpermission are rejected by Permission Admissibility (§10.4.7), reported asE-TYP-1601for both root places and subplaces.
19.4.5 Worked example
Section titled “19.4.5 Worked example”procedure advanceCursor(state: var PlaybackState, delta: u32) -> () { state.frame_index += delta // compound assignment on a numeric field state.is_dirty = true // plain assignment to a bool field}19.5 Expression Statements (§18.5)
Section titled “19.5 Expression Statements (§18.5)”19.5.1 Grammar
Section titled “19.5.1 Grammar”expr_stmt ::= expression terminatorAn expression statement evaluates an expression for its effects and discards its value. It requires a terminator (newline or ;).
T-ExprStmt requires only that the expression be well-typed (e : T); the resulting value is discarded and the statement leaves the environment unchanged. ExecSigma-ExprStmt evaluates the expression and maps its outcome to a statement outcome (StmtOutOf): a normal value becomes ok; a control transfer propagates.
flushQueue(render_queue) // call evaluated for its effect; result discardedcounter.increment() // method call as an expression statementA block, if, loop, unsafe, or key block used in statement position is an expression statement when it is not the block tail. No additional diagnostics are introduced beyond those of the contained expression and the terminator rules of §19.1.
19.6 defer (§18.6)
Section titled “19.6 defer (§18.6)”19.6.1 Grammar
Section titled “19.6.1 Grammar”defer_stmt ::= "defer" block_exprdefer schedules a block to run when the enclosing scope exits. The deferred block runs whether the scope exits normally, by return, by break / continue, or during a panic unwind.
19.6.2 Semantics
Section titled “19.6.2 Semantics”- The deferred block must have type
()(unit). A non-unit deferred block isDefer-NonUnit-Err(E-SEM-3151). - The deferred block must not contain non-local control flow — no
return, and nobreak/continuethat would leave the deferred block. The predicate isDeferSafe(b) ⇔ ¬ HasNonLocalCtrl(b, false). Abreak/continueinside a loop that is itself inside the deferred block is fine (thein_loopflag becomestrue); what is forbidden is control that escapes the deferred block. A violation isDefer-NonLocal-Err(E-SEM-3152). - Registration is dynamic: when execution reaches the
deferstatement, the block is appended to the scope’s cleanup list (ExecSigma-Defer→AppendCleanup). Adeferthat is never reached is never registered and never run.
19.6.3 Ordering — LIFO
Section titled “19.6.3 Ordering — LIFO”Deferred blocks run in reverse registration order (last-in, first-out). Because cleanups are appended as they are reached and run from the most recent at scope exit, the deferred block registered last runs first. Deferred blocks and value destructors share the same cleanup list and unwind together at scope exit (Cleanup-Step-Defer-*).
19.6.4 Worked example
Section titled “19.6.4 Worked example”procedure processBatch(source_path: string, dest_path: string) -> () { let input := openFile(source_path) defer { closeFile(input) }
let output := createFile(dest_path) defer { closeFile(output) }
copyContents(input, output)}Registration order is input’s defer, then output’s defer. At scope exit they run LIFO: closeFile(output) first, then closeFile(input). If copyContents panics, both deferred blocks still run during unwind, in the same LIFO order.
19.7 region — Arena Scopes (§18.7)
Section titled “19.7 region — Arena Scopes (§18.7)”19.7.1 Grammar
Section titled “19.7.1 Grammar”region_stmt ::= "region" region_opts? region_alias? block_exprregion_opts ::= "(" expression ")"region_alias ::= "as" identifierA region statement opens an arena for the duration of its block. Allocations made into the arena with new or a Region@Active handle’s ~>alloc method (§19.7.4) live until the region’s block exits, at which point the entire arena is released at once (ReleaseArena). A region thus amortizes many small allocations into one bulk reservation and one bulk free.
19.7.2 Options and alias
Section titled “19.7.2 Options and alias”-
region_optsis a parenthesized expression of typeRegionOptions. When omitted, the options default to a zero-argumentRegionOptions()call:RegionOptsExpr(⊥) = Call(Identifier(`RegionOptions`), [])RegionOptionsis a public built-in record (BuiltinRecord) with twopublicfields, both defaulted:stack_size: usize— bytes to pre-reserve for the arena (default0, meaning no pre-reservation;RegionPrealloc(opts) = opts.stack_size).name: string— a diagnostic name (default the empty string).
-
region_alias(as name) binds an identifier of typeunique Region@Activeto the region inside the block, so it can receive~>alloccalls or be handed to aframe. The region binding has typeTypePerm(unique, TypeModalState([Region], @Active)).If no alias is given, the region binding is synthetic: it is not introduced by name resolution and cannot be referenced by user code (
T-RegionStmt). Source code can still allocate into the anonymous region withnew; useasonly when code needs to name the region explicitly or pass it to aframe.
19.7.3 Semantics
Section titled “19.7.3 Semantics”T-RegionStmt: the options expression is checked against RegionOptions (opts ⇐ TypePath([RegionOptions])), the region binding is introduced into a fresh scope by RegionBind, and the body is typed in that scope. The statement itself produces unit and leaves the outer environment unchanged.
Dynamically (ExecSigma-Region): evaluate the options, create a new arena (RegionNew), bind the alias if present (BindRegionAlias), evaluate the body in the region’s scope (EvalInScopeSigma), then RegionRelease — run scope cleanup (deferred blocks / destructors), then ReleaseArena frees the whole arena, then pop the scope. A control transfer out of the body still releases the arena on the way out (ExecSigma-Region propagates out').
19.7.4 Allocating into a region
Section titled “19.7.4 Allocating into a region”Current-region allocation uses new; explicit-target allocation uses the
Region modal method-call form:
new_expr ::= "new" unary_exprpostfix_suffix ::= "~>" identifier "(" argument_list? ")"new value allocates value into the innermost active scoped region.
region_handle~>alloc(value) allocates value into the region named by the
receiver. The receiver must type as unique Region@Active. The allocated
expression keeps its value type T; the result carries the target region’s
provenance, so the value may not escape the region’s lifetime. A value with
shorter-lived provenance escaping to a longer-lived location is E-MEM-3020.
19.7.5 Worked example
Section titled “19.7.5 Worked example”procedure buildScene(scene_spec: SceneSpec) -> SceneReport { let report_count: usize = scene_spec.nodeCount()
region (RegionOptions { stack_size: 64 * 1024, name: "scene" }) { let nodes := new allocateNodes(scene_spec) let edges := new allocateEdges(scene_spec, nodes)
wireGraph(nodes, edges) } // Everything allocated into the scene region is released here, at once.
return SceneReport { node_count: report_count }}nodes and edges live in the scene region; both are freed in one bulk release when the region block exits. Nothing allocated into the arena may escape the block (it would be a provenance escape, E-MEM-3020).
19.8 frame — Stack-Like Scopes (§18.8)
Section titled “19.8 frame — Stack-Like Scopes (§18.8)”19.8.1 Grammar
Section titled “19.8.1 Grammar”frame_stmt ::= "frame" block_expr | identifier "." "frame" block_exprA frame is a stack-like sub-scope of an existing region. On entry it records a mark (FrameMark, the region’s current high-water position); on exit it resets the region back to that mark (ResetArena), reclaiming everything the frame allocated while leaving earlier region allocations intact. Unlike a region, a frame does not own an arena — it borrows the enclosing region and rolls it back.
19.8.2 Implicit vs explicit target
Section titled “19.8.2 Implicit vs explicit target”- Implicit —
frame { ... }: targets the innermost active region in scope (FrameBind(Γ, ⊥)viaInnermostActiveRegion). If there is no active region in scope, it isFrame-NoActiveRegion-Err(E-MEM-1207). - Explicit —
region_alias.frame { ... }: targets the named region. The identifier must resolve to a value whose type satisfiesRegionActiveType(i.e.Region@Active); otherwise it isFrame-Target-NotActive-Err(E-MEM-1208).
FrameBind introduces a fresh synthetic region identifier F for provenance only; it carries the same synthetic-binding restriction as an anonymous region binding and cannot be referenced by user code. new inside a frame targets that frame scope. A named handle’s ~>alloc call still targets the named region explicitly.
19.8.3 Semantics
Section titled “19.8.3 Semantics”Dynamically (ExecSigma-Frame-Implicit / -Explicit): resolve the target region, FrameEnter (push a scope, record mark = FrameMark(...)), evaluate the body in that scope, then FrameReset — run scope cleanup, then ResetArena(..., mark) rolls the region back to the recorded mark, then pop the scope. A control transfer out of the body still resets the region on the way out.
19.8.4 Worked example
Section titled “19.8.4 Worked example”procedure renderFrames(scratch: unique Region@Active, work_items: [WorkItem]) -> () { loop item in work_items { // Each iteration allocates scratch data, then rolls it back. scratch.frame { let staging := new buildStaging(item) let temp_mesh := new tessellate(staging) submitToGpu(temp_mesh) } // `staging` and `temp_mesh` are reclaimed; `scratch` is reset to the mark. }}The region scratch is reused across every iteration. Each frame allocates into scratch, then resets it to the entry mark on exit, so peak memory is one iteration’s worth rather than the whole loop’s. The implicit frame { ... } form targets the innermost active region without naming it:
region as work_arena { frame { let buffer := new allocScratch(1024) useBuffer(buffer) } // work_arena reset to the mark taken at `frame` entry.}19.9 Control-Transfer Statements: return, break, continue (§18.9)
Section titled “19.9 Control-Transfer Statements: return, break, continue (§18.9)”19.9.1 Grammar
Section titled “19.9.1 Grammar”return_stmt ::= "return" expression? terminator?break_stmt ::= "break" expression? terminator?continue_stmt ::= "continue" terminator?There are no loop labels. break and continue target the innermost enclosing loop (the Control-Flow chapter, §16.7, defines loop; the loop flag L = loop records that a loop encloses the statement). return exits the enclosing procedure / method / transition body.
19.9.2 return
Section titled “19.9.2 return”return e exits the current body, yielding e; return with no expression yields ().
T-Return-Value: the return expression, viewed throughReturnDestExpr(which insertsmovefor a place orcopyfor a copy expression), is checked against the body’s declared return typeR_b = BodyReturnType(R).T-Return-Unit: barereturnis valid only when the body’s return type is().- A returned owned place transfers its provenance/allocation domain to the return destination; a fresh expression materializes directly there; a
copyfirst creates a duplicate domain that becomes the returned owner (ReturnDestExpr,EvalReturnDest,LowerReturnDest).
Diagnostics: return type mismatch (Return-Type-Err, also Return-Unit-Err for a non-unit body with bare return) is E-SEM-3161; invalid async return type is Return-Async-Type-Err / Return-Async-Unit-Err; return at module scope is E-SEM-3165.
A bare trailing return e as the final statement of a block makes the block’s type ! (never) via BlockInfo-ReturnTail (§19.1.3).
19.9.3 break
Section titled “19.9.3 break”break exits the innermost loop. It is valid only inside a loop (L = loop); outside a loop it is Break-Outside-Loop (E-SEM-3162).
T-Break-Value:break ecarries the valueeout as a loop result type (Brk = [T]).T-Break-Unit: barebreakcarries unit and marks the loop as a void-break (BrkVoid = true).
A loop used as an expression takes its value from its break expressions. The loop type is computed by LoopTypeInf / LoopTypeFin: a loop with no break has type (); a loop whose value-breaks all agree on T (and which has no void-break) has type T; mixing value and void breaks, or value-breaks with no common type, is ill-typed.
19.9.4 continue
Section titled “19.9.4 continue”continue skips to the next iteration of the innermost loop. It carries no value. It is valid only inside a loop (L = loop); outside a loop it is Continue-Outside-Loop (E-SEM-3163).
19.9.5 Interaction with cleanup
Section titled “19.9.5 Interaction with cleanup”Control transfer does not bypass cleanup. return, break, and continue each run pending defer blocks and value destructors for every scope they exit, in LIFO order, before the transfer completes. Temporary cleanup is emitted immediately before the transfer in lowering (Lower-Stmt-Return / -Break / -Continue with TempCleanupIR), and the returned owner is excluded from temporary cleanup (TempCleanupIR(s \ ReturnedOwner(e))) so the returned value is not dropped.
19.9.6 Worked example
Section titled “19.9.6 Worked example”procedure firstReady(devices: [Device]) -> u32 { var index: u32 = 0 loop device in devices { if device.isFaulted() { index += 1 continue // next iteration of the innermost loop } if device.isReady() { return index // exits the procedure; runs pending cleanup } index += 1 } return index}A loop used as an expression takes its value from break. An infinite loop (no iterator, no condition) that exits only via break candidate has type u32 because every break agrees on u32:
procedure findMatch(node_count: u32) -> u32 { var probe: u32 = 0 let found_index: u32 = loop { if probe == node_count { break node_count // sentinel: completed without a match } if isMatch(probe) { break probe // loop evaluates to this value } probe += 1 } return found_index}Both break expressions carry u32, so the loop’s value type is u32. (Ultraviolet has no loop ... else: a loop’s fall-through value is (), so a loop used for a non-unit value must reach every exit through a value-carrying break.)
19.10 unsafe Statements (§18.10)
Section titled “19.10 unsafe Statements (§18.10)”19.10.1 Grammar
Section titled “19.10.1 Grammar”unsafe_block ::= "unsafe" block_exprAn unsafe statement runs its block in an unsafe context, permitting operations that require an explicit unsafe boundary. The block is a normal block: it has whatever type its tail produces (T-UnsafeStmt: b : T), and its value/outcome are exactly the block’s (ExecSigma-UnsafeStmt). unsafe is also available in expression position (unsafe_expr, §16.8.1); the statement form is the same construct used where a statement is expected.
19.10.2 What unsafe enables
Section titled “19.10.2 What unsafe enables”The unsafe block does not introduce diagnostics of its own. Instead, specific operations require being inside an unsafe span (UnsafeSpan(...)), and each owning construct reports the violation when it is used outside one. Operations that require unsafe include:
- references to packed fields under
#layout(packed)(AddrOfOkrequiresUnsafeSpanwhenPackedField(p)), transmute(Transmute-Unsafe-Err),- raw allocator operations — raw
alloc/dealloc(AllocRaw-Unsafe-Err,DeallocRaw-Unsafe-Err), - unchecked region operations such as
Region::reset_uncheckedandRegion::free_unchecked(Region-Unchecked-Unsafe-Err), - raw-pointer dereference (
T-Deref-Raw), extern(foreign) calls.
The unsafe-required-operation diagnostic is E-MEM-3030 (owning rules: AllocRaw-Unsafe-Err, DeallocRaw-Unsafe-Err, Region-Unchecked-Unsafe-Err, Transmute-Unsafe-Err).
19.10.3 Worked example
Section titled “19.10.3 Worked example”procedure reinterpretBits(raw: u32) -> f32 { // `transmute<T1, T2>(e)` requires an unsafe context. let bits: f32 = unsafe { transmute<u32, f32>(raw) } return bits}Keep the unsafe block as small as possible — exactly the operation that needs it — and wrap it in a safe API that re-establishes project invariants (style guide; see §19.11).
19.11 Idioms & Best Practices
Section titled “19.11 Idioms & Best Practices”Grounded in the Ultraviolet style guide (AGENTS.md) and the spec semantics above:
- Prefer
lettovar. Reach forvaronly when the binding is genuinely reassigned. Immutable bindings communicate intent and let the checker prove more. Use:=for owning, must-not-escape resources whose destructor must run in place. - Let the block tail carry the value. Express a block’s result as a bare tail expression rather than assigning to an outer
varand falling out. This keeps block types precise (§19.1.3). - Newlines terminate statements. Use
;only to justify several short statements on one line; do not pepper code with semicolons. Use same-line K&R braces and 4-space indentation, targeting a 100-column maximum. - Write explicit
returnin non-unitprocedures. The style guide prefers explicitreturnfor clarity even where a tail expression would also work. - Pair acquisition with
deferimmediately. Register cleanup right after the resource is acquired, so the LIFO order mirrors acquisition order and no path can skip it. Preferdeferover hand-written cleanup before eachreturn. - Use
regionfor bulk-lifetime allocation,framefor per-iteration scratch. Aregionamortizes many allocations into one bulk free; aframeresets a region to a mark so a loop reuses scratch memory without per-iteration heap traffic. Usenewfor the current scope. Name a region withaswhen code must target it explicitly or hand it to aframe; leave it anonymous when current-scope allocation is enough. - Keep
unsafeminimal and wrapped. Make theunsafeblock exactly the operation that requires it, and expose a safe wrapper that restores invariants. More code is not a justification for widening anunsafespan. - Alias with
using ... asonly when it earns its place. Do not use a localusingto simulate shadowing or to rename for cosmetics. Wildcardusing module::*is reserved for internal or implementation modules; never use it in a public API surface. - Do not let allocated values escape their region/frame. Treat the region/frame block as the lifetime boundary; return owned heap values, not arena-provenance values.
19.12 Pitfalls & Diagnostics
Section titled “19.12 Pitfalls & Diagnostics”| Situation | Rule | Code |
|---|---|---|
Assigning to a let binding | Assign-Immutable-Err | E-MOD-2401 |
| Assignment target is not a place expression | Assign-NotPlace | E-SEM-3131 |
| Assignment type mismatch / annotation mismatch | Assign-Type-Err, T-LetStmt-Ann-Mismatch | E-SEM-3133 |
| Compound assignment on a non-numeric place | Assign-Type-Err | E-SEM-3133 |
Compound assignment on a let place | Assign-Immutable-Err | E-MOD-2401 |
Assigning through a const-permission place | Permission Admissibility (§10.4.7) | E-TYP-1601 |
shared write with no valid key-mediated context | §10.4.7 / Key System | E-TYP-1604 |
defer block has non-unit type | Defer-NonUnit-Err | E-SEM-3151 |
return / break / continue escaping a defer block | Defer-NonLocal-Err | E-SEM-3152 |
return type mismatch with the body | Return-Type-Err, Return-Unit-Err | E-SEM-3161 |
break outside a loop | Break-Outside-Loop | E-SEM-3162 |
continue outside a loop | Continue-Outside-Loop | E-SEM-3163 |
return at module scope | — | E-SEM-3165 |
frame with no active region in scope | Frame-NoActiveRegion-Err | E-MEM-1207 |
r.frame target not in Region@Active | Frame-Target-NotActive-Err | E-MEM-1208 |
new allocation with no active region in scope | New-NoActiveRegion-Err | E-MEM-3021 |
| Arena value escaping its region/frame (shorter-lived provenance) | provenance escape | E-MEM-3020 |
unique binding from a place without move | B-LetVar-UniqueNonMove-Err | E-MEM-3007 |
Moving from an immovable (:=) binding | Trans-Let-NoReassign, B-Closure-MoveCapture-Immovable-Err | E-MEM-3006 |
Unsafe-required op outside an unsafe block | AllocRaw-Unsafe-Err, DeallocRaw-Unsafe-Err, Region-Unchecked-Unsafe-Err, Transmute-Unsafe-Err | E-MEM-3030 |
Refutable pattern in let / var | Let-Refutable-Pattern-Err | — |
Common traps:
- A trailing terminator turns the tail into a statement.
{ ...; value }has typeT, but{ ...; value; }has type()— the;makesvaluean expression statement and the block falls out with unit. If a block “lost its value,” check for a stray terminator after the last expression (§19.1.3). break valueand the loop result type. Every value-breakin one loop must agree on a common type, and a loop must not mix a value-breakwith a bare void-break. A loop with nobreakhas type()(LoopTypeFin); to use a loop for a non-unit value, reach every exit through a value-carryingbreak.deferthat is never reached never runs. Registration is dynamic. If adefersits after an earlyreturn, it is not registered on that path. Register cleanup as early as possible, right after acquisition.- Refutable pattern in
let.let Outcome::Value(x) = resultis rejected (Let-Refutable-Pattern-Err); useif result is Outcome::Value(x) { ... }or aloop ... ininstead. frameneeds a region. A bareframe { ... }with no enclosingregion(and no in-scopeRegion@Active) isE-MEM-1207. Open aregionfirst, or pass an active region and useregion_alias.frame.- No loop labels, no
loop ... else.break/continuealways bind to the innermostloop, andelseattaches only toifforms (ElseCont/§16.7.1), never to a loop. To affect an outer loop, restructure with a flag, avar, or an earlyreturn. - Control-flow bodies parse through
ParseBlock. The grammar reads everyif/loop/elsebody viaParseBlock, which requires{(§18.1.2). Keep braces on control-flow bodies so the code parses as written.