Files
brolang/TODO.md
T
2026-06-28 23:27:59 +02:00

52 KiB
Raw Blame History

"quick" / "easy" fixes

  • for global initialization cycles, report also starting and ending lines

milestones

  1. interop type foundation (implemented)
  • unsigned integers, floats, and target-dependent c scalar types
  • atomic c_* primitive types remain distinct until target-aware lowering
  • c_func and pointer-only c_struct; c remains an ordinary identifier
  • keep binding mutability (:: / =) separate from element or pointee mutability (mut)
  • arrays and indexing
    • [N]T: array with N logical elements
    • [N;S]T: array with N logical elements followed by sentinel S
  • pointers
    • @T / @mut T: non-null single-item pointer without arithmetic
    • *T / *mut T: non-null many-item pointer with arithmetic
    • optional pointers represent nullable pointers (i.e. ?@T / ?@mut T, ?*T / ?*mut T)
  • slices and slicing
    • []T: pointer and length
    • [;S]T: pointer and length with a sentinel invariant
    • ordinary slices do not guarantee null termination
  • string literals as immutable sentinel slices backed by static arrays (superseded by milestone 3.5)
  • character literals
  • optionals with trapping unwrap and fallback operations
  • native structs with compiler-controlled layout
  • pointer-only c_struct support with target c layout
    • Some :: c_struct { ... }: defined c-layout struct
    • Some :: c_struct: opaque c-layout struct
    • passing c structs by value was deferred until milestone 4.1
  1. restricted c header imports (implemented)
  • treat an imported header as a synthetic, file-local package namespace
    • native :: import "relative/path/to/header.h"
  • import functions, typedefs, scalar types, and pointers to opaque records
  • keep implementation linking separate from header imports
  • cache imports by canonical header path and target/include/define configuration
  • diagnose unsupported declarations when referenced
  • dynamically load libclang behind a replaceable c importer boundary
  1. c variadic calls (implemented)
  • represent c variadics as a fixed parameter count plus a variadic flag
  • apply c default argument promotions at call sites
  • emit LLVM c-variadic declarations and calls
  • keep native brolang variadics and tuple design separate

3.5. sentinel pointers and c strings (implemented)

  • add sentinel many-item pointers: [*;S]T
  • represent string literals as immutable pointers to statically stored sentinel arrays: @[N;0]u8
  • arrays expose .len but no .ptr; slices and pointers-to-arrays expose sentinel-preserving .ptr
  • allow pointer-to-array .len, indexing, slicing, pointer decay, and slice construction without explicit dereference
  • preserve or forget sentinel information through compatible pointer and slice coercions without copying arrays
  • allow zero-terminated immutable byte pointer views to convert to immutable *c_char and [*;0]c_char
  • keep u8 and c_char distinct to preserve target-dependent scalar c semantics
  • reject general u8/c_char interchange, slice-to-pointer coercion, and conversion to mutable c character pointers
  1. advanced c interop
  • by-value records and unions (implemented)
    • complete plain imported structs/unions and manual c_struct values
    • fixed C arrays inside imported records
    • keyed struct literals and exactly-one-field union literals
    • field reads/writes, storage, and fixed-signature calls/returns
    • aarch64-macos small aggregate, homogeneous float aggregate, and indirect ABI lowering
    • keep incomplete, bitfield, packed, flexible-array, qualified-field, and otherwise non-plain records pointer-only
    • keep C variadic record arguments unsupported
  • function pointers and callbacks (implemented)
    • imported C function pointer typedefs lower to nullable pointer types
    • manual ?*c_func(...) T callback type spelling
    • concrete c_func declarations/definitions can be passed as callback values
    • postfix calls through non-null function pointers, including callback?(...)
    • fixed and C-variadic callback ABI emission through LLVM indirect calls
  • external variables (implemented)
    • imported external C object variables lower to direct LLVM external global references
    • top-level const object variables are read-only from brolang
    • mutable external scalars/records can be assigned through qualified package globals
    • unsupported variable types remain lazy diagnostics when referenced
  • object-like macro constants (implemented)
    • scalar integer/float literal macros import as immutable globals
    • CLITERAL(Type){ ... } / (Type){ ... } record literal macros import as immutable globals
    • function-like macros and non-literal macro expressions remain unsupported
  • static inline functions (implemented)
  1. control flow (implemented)
  • boolean expressions (implemented)
    • bool type with true / false literals
    • comparison operators: ==, !=, <, <=, >, >= (numeric operands widen; bool supports only == / !=)
    • operators: and, or, !
    • lazy evaluation / short-circuit evaluation
  • if statements (implemented). example: if condition { ... } else if { ... } else { ... }
    • conditions must be bool; block-scoped locals with shadowing across blocks
    • lowered through new Label / Br / Cond_Br IR opcodes (alloca-backed locals, no phi nodes)
  • conditional unwrapping for optionals (?T) (implemented): if val |v| { ... } else { ... } - unwrap val into v if it is not none
    • single immutable binding scoped to the then-block; v not visible in else or after the if
    • | lexes as a new Pipe token; the .If reuses AST name / HIR local to carry the binding (no new statement kind)
    • new Optional_Is_Some / Optional_Value IR opcodes (the Unwrap presence-test + extract, minus the trap)
  • conditional unwrapping with guard clause (implemented): if val |v : v >= 10| { ... } else { ... } - enter the then-block when val is not none and the guard is true
  • multi-unwrap (implemented; see section below)
  • while loops (implemented; operates on boolean conditions). examples:
    • while condition { ... } - iterate while the condition is true
    • while condition : i = i + 1 { ... } - execute the update after each completed iteration
    • the condition and update may be parenthesized independently for visual clarity
    • update targets must already be declared and mutable; loops do not introduce implicit induction variables
    • update clauses support ordinary and compound assignment
  • ranges (implemented; see section below)
  • for loops (implemented; operates on ranges, arrays, slices, and pointers-to-arrays). examples:
    • for items |item| { ... } - capture just the item value in the array/slice (uses copy semantics, i.e. gets a T)
    • for (&items) |@item| { ... } - capture a pointer to each array element; its @T / @mut T mutability follows the iterable
    • for items_slice |@item| { ... } - slices already refer to backing storage and support pointer capture directly
    • for items |item, idx| { ... } - capture item and its index index in the array/slice
    • for 0..10 |i| { ... } - iterate over the range 0..10 (exclusive)
    • for 0..=10 |i| { ... } - iterate over the range 0..10 (inclusive)
    • for 0..(len) |i| { ... } or equivalently for 0..=(len - 1) |i| { ... } - calculating range bounds, expressions must be parenthesized
  • for all conditionals/guards, parentheses are optional but allowed for visual clarity
  1. compound assignment: +=, -=, *=, /= (implemented)
  • added the binary arithmetic operators -, *, / (previously only + existed); *// bind tighter than +/-, and prefix - (negation) is unchanged
  • compound assignments preserve the target, operator, and right-hand side explicitly through parsing and checking; lowering computes the target address once, then loads, applies the operation, and stores through that address
  • side-effecting index, field-base, and dereference expressions are evaluated once in left-to-right order
  • integer arithmetic traps on overflow (Sub_Checked/Mul_Checked via the LLVM .with.overflow intrinsics) and integer / traps on divide-by-zero and INT_MIN / -1; floats follow IEEE (fadd/fsub/fmul/fdiv, no trap)
  • constant folding (global initializers) covers -, *, / alongside +
  1. enums (native and c interop) (implemented; see below)
  • native enums are nominal value types with integer runtime representations
  • unbacked enums are non-empty, dense, zero-based, and use the smallest fitting unsigned backing
  • explicitly backed enums require an integer type and strictly increasing literal values
  • enum members support Type.member, package.Type.member, and contextual .member
  • enum values support storage, calls/returns, and same-type equality/inequality
  • explicitly backed native enums use their backing ABI in c_func signatures and variadic promotion
  • imported C enum types alias libclang's target-selected integer backing and enumerators import as package constants
  1. distinct types (implemented; see below)
  • nominal declarations preserve identity across packages and reuse the backing runtime representation
  • construction uses Type(value) with exactly one value of the exact backing type
  • no implicit conversion to or from the backing type
  • backing-type operators and reverse explicit conversions remain deferred
  • concrete runtime backing types are supported; unresolved, int, void, function, and opaque backings are rejected
  1. allow pointer field access pass-through (implemented)
  • having a pointer (ptr) to a struct, we should allow access through ptr.field as opposed to mandating ptr^.field
  1. make slice expressions on array variables implicitly address-taking (implemented)
  • zig's slice expression on an array variable handles the address-taking implicitly (nice ergonomics)
  • arr[a..b] on an array variable now slices without the explicit &; the explicit (&arr)[a..b] pointer-to-array form keeps working unchanged
  • array rvalues (e.g. a by-value array return) are materialized into a temporary before slicing, matching the for-loop iterable lowering
  1. c header imports and automatic native brolang bindings (implemented)
  • brolang translate-c <header.h> [--target ...] [--c-include-path ...] [--c-define ...] prints native .bro bindings for a C header to stdout (the offline counterpart of the in-memory native :: import "x.h"); reuses the libclang cimport.Result
  • emitter lives in compiler/translatec; render_type mirrors loader.translate_c_type one-to-one so emitted source re-parses to identical types (guarded by a round-trip test)
  • added a native type-alias declaration Name :: alias T (parser/lexer/token surface; the types.define_alias / .Alias machinery already existed) so C typedefs and callback typedefs round-trip
  • emits functions, complete/opaque structs (collapsing typedef struct {...} Foo), typedef aliases, and scalar/aggregate/enum-member constants
  • C unions, external variables, static-inline functions, and unsupported declarations have no hand-writable spelling and are emitted as # unsupported in bindings: comments (functions that reference an un-spellable union therefore keep a dangling reference)
  1. undefined as inspired by zig (implemented):
  • allow mutable local declarations with undefined
    • undefined values are assigned a poison value (0xaa...)
    • allows for something like:
      a int = undefined
      if (condition) {
          a = 42
      } else {
          a = -2
      }
      
  • disallow: b :: undefined since assigning undefined to something that can't change defeats the purpose
  • disallow assigning undefined after declaration; use optionals and none for values that intentionally move back to an empty state
  1. introduce float and range type constraints (the int family generalized) (implemented)
  • float resolves a local binding to any float scalar (f32/f64) via static analysis; widens f32 -> f64 across assignments, mirroring how int picks the smallest integer
  • on a local declaration, integer literals satisfy float and default to f64 (pi float = 3 is 3.0); a runtime integer (x float = some_i32) stays a cannot implicitly convert error
  • range is now a spellable type/constraint: r range :: 0..10 resolves to the inferred range (element type preserved), and func(start, end int) range { return start..end } monomorphizes the result per call. this replaces the prior int-as-passthrough hack that was the only way to forward a range through a function
  • int/float/range constraints now gate by family in every position (previously params/results were unchecked generic passthroughs):
    • a local initializer out of family errors instead of silently taking the natural type
    • a param rejects an out-of-family argument (cannot pass f64 to 'int' parameter 'x'); a float param accepts an integer-literal argument as f64 (e.g. f(3))
    • a function result is narrowed to the constraint's family
  1. broaden type inference to surrounding context (implemented)
  • a slot's concrete type is the join of demands reachable from its declaration, flowing backward as well as forward to a fixpoint (the existing global/spec fixpoint in infer_all), generalizing milestone 13's forward-only resolution
  • an "open constant" (a global or local with no concrete annotation plus a compile-time integer initializer) is sign-agnostic until used: a backward demand from any reachable use picks its family/width as long as the value fits, so A :: 10 followed by B u16 :: A resolves both to u16 — the literal's smallest-signed default no longer blocks an unsigned demand; absent any demand it defaults to the smallest signed type
  • a concrete declared type flows backward through a chain of bare-name references: X :: 1000; Y int :: X; Z i32 :: Y resolves X and Y to i32 (previously they stayed at the literal's i16)
  • locals resolve identically to globals (no scope asymmetry): demands flow through bare-name typed declarations, call arguments (a concrete parameter type demands its argument, e.g. take_u16(a)), and returns — including from inside a function body back onto a referenced global
  • at this milestone, demands flow only through bare names; they do not cross arithmetic or other operators, nor back across a call's result (arithmetic is milestone 15; result-to-argument direction is milestone 14.5)
  • a non-fitting or family-conflicting demand is not applied (first demand wins); the genuine mismatch then surfaces as the usual boundary coercion error at the use (e.g. C u8 :: BIG where BIG :: 100000)

14.5. backward type-demand propagation through call boundaries (DEFERRED)

  • a callee's result/return demand flows back through the function body to constrain the caller's arguments, so R u32 :: echo(A) (with echo :: func(p int) int) resolves A to u32 instead of erroring at the call's result coercion
  • requires reversing the per-call data flow: a specialization's argument types (spec.args) become outputs to solve, not just inputs — a new back-edge threaded through every call site and the specialization fixpoint
  • only meaningful on top of milestone 14's open constants
  1. broaden type inference to infer type of declaration based on arithmetic expressions too (implemented)
  • backward contextual demands now flow through numeric arithmetic (+, -, *, /, unary -) for integer and float open constants
  • integer literals can adopt integer or float arithmetic context; float literals can adopt f32/f64
  • unannotated declarations initialized by arithmetic expressions adopt the concrete numeric operand type
  • e.g.
    a :: 1
    b i32 :: a + 2 # a is constrained to `i32`
    c :: b + 3     # c is constrained to `i32`
    
  1. for if statements, allow if (cond) one-line statement or if some_func(some_arg) one-line statement (instead of forcing either if (cond) { block } or if cond { block }) (implemented)
  • if statements without a bracketed body must wrap the condition in parentheses UNLESS it's a function call
  • brace-less single-statement bodies apply to the then-body, the else-body, and the unwrap/guard forms (if v |x| stmt); each branch is independent, so braced and brace-less branches mix freely
  • the parenthesize-or-call rule constrains only the then-branch condition; else and the unwrap |...| already delimit, so they need no parentheses
  • the brace-less statement may sit on the line after the condition
  • parser-only change (parse_branch_body in compiler/parser/parser.odin): a brace-less body is just a 1-element statement slice, so the checker and codegen are unchanged
  1. multi-line strings (implemented; see below)
  • a multi-line string is an ordinary string literal under the hood: it lowers to the same .String expr / @[N;0]u8 type, so the checker, lowering, and codegen are unchanged. only the lexer and parser change.
  • the lexer (compiler/lexer/lexer.odin, case '`') collapses consecutive backtick-marked lines into one Multiline_String token; the trailing newline after the last line stays a .Newline so it terminates the statement normally
  • the parser (decode_multiline_string in compiler/parser/parser.odin) strips each line's leading indentation and `, takes the rest of the line raw (no escapes), and joins lines with an implicit \n (no leading/trailing newline); an empty ` yields a blank line
  • the value may sit on the line after =/:: (the existing post-operator skip_newlines already allows this)
  • ++ concatenation (the spec's "mixing" examples) is a separate, unimplemented operator and is out of scope here
  1. add break and continue statements (implemented)
  • break exits the innermost enclosing loop; continue skips to that loop's next iteration (running the while update / for index increment first). Both target the innermost loop only (no labeled break) and carry no value
  • new Keyword_Break/Keyword_Continue tokens; Break/Continue AST and HIR statement kinds (no fields beyond kind/span); parsed by parse_loop_control
  • the checker tracks loop nesting (Build_Ctx.loop_depth, bumped around loop-body builds) and rejects break/continue outside a loop; all_paths_return no longer treats a while true whose body can break as non-terminating (so a non-void function that breaks out without returning is correctly diagnosed)
  • lowering keeps an innermost-last loop-target stack (State.loops): break branches to the loop's exit label, continue to its update/latch label. The range-for routes continue through the end-of-iteration bounds/overflow guard, so for 0..=255 |b: u8| exits cleanly instead of overflowing the increment
  • the LLVM emitter opens a fresh recovery block after any terminator (not just ret), so dead code following a break/continue branch stays well-formed
  1. add defer statement (inspired by zig) (implemented; also adds bare block statements)
  • defer <stmt> runs the statement when the enclosing block scope exits, in reverse (LIFO) order, on every exit path: fall-through, return, break, continue. The deferred statement may be a block (defer { ... })
  • bare block statements { ... } were added as the enabling feature: a { ... } introduces a nested scope (locals are name-scoped to it; defers inside it fire at the closing brace). A leading { is unambiguous since struct literals are postfix only
  • the return value is captured before defers run (a defer that mutates the returned local can't change what is returned) — the checker spills the return value into a temp local, then flushes, matching Zig
  • return flushes all active defers; break/continue flush only down to and including the innermost loop body; fall-through flushes the current block's own defers. Deferring a return/break/continue/defer, a return inside a defer, or a break/ continue that would escape a defer are all rejected
  • implemented entirely in lexer/parser/checker (new Keyword_Defer; Block/Defer AST kinds reusing body/update; parse_block_statement/parse_defer). No HIR opcode: a bare block is built and spliced inline, and a deferred statement is built once at the defer site and its hir replayed at each exit, so lowering/codegen are unchanged
  1. add yield statement (implemented; first pass — value blocks only; see below)
  • a { ... } on the right of a declaration or assignment is a value block: its final statement must be yield <expr>, which supplies the block's value (the block analogue of return). Supported: x :: { ...; yield v } (untyped — the local takes the yield's natural type), x T = { ... } (coerces to T), and target = { ... } (coerces to the target's type, including complex targets like a[i] = { ... })
  • the yielded value is captured before the block's defers run (a defer that mutates a block local can't change what is yielded), reusing the return spill-to-temp pattern
  • yield is valid only as the final statement of a value block. A yield nested in an if/loop/inner block, or in a non-value block, is rejected ("'yield' is only valid as the final statement of a value block"); a value block not ending in yield is rejected too. This no-early-exit restriction keeps it a lexer/parser/checker-only change with no HIR/lowering touch (like milestones 18/19)
  • new Keyword_Yield token + .Yield AST stmt (reuses expr); a block-initialized Declaration/Assignment reuses the existing body field with expr invalid. The checker's build_value_block builds the leading statements (via build_block with a new close=false flag that keeps the scope open), evaluates the final yield, spills and flushes the block's defers, then feeds the value into an ordinary Declaration/ Assignment. HIR never holds a .Yield (final yield → Declaration/Assignment, misplaced yield → Trap), so lowering/codegen are unchanged
  • deferred to a later milestone (needs labeled blocks + rules for whether an if/loop always produces a value, e.g. optionals): yield from inside if/loops, labeled blocks (blk: { yield :blk v }), implicit trailing-expression yield, and yield in match arms / catch handlers (milestones 2122)

20.5 yield from if-statements and loops (implemented; see below)

  • an if/for/while on the right of a declaration or assignment is now a value source, governed by the rule if one path yields, all paths must yield (no optionals-as-a-crutch, so the value is always present and never needs unwrapping):
    • value-if: result :: if a { yield 1 } else if b { yield 2 } else { yield 3 } — a mandatory else, every branch ends in yield, all branches share a type (the first branch fixes it when untyped; later branches coerce). Typed T = and reassignment target = if … are supported too
    • value-loop: a labeled body for/while … blk: { … } whose early exits are yield :blk x and whose body ends in an unlabeled fall-through yield (the value when the loop completes). The {T, none} yields resolve the result to ?T (a pure-AST none-scan picks optionality; the first concrete yield fixes the element type). E.g. active_ent_idx :: for 0..10 |i| blk: { if (cond) yield :blk i; yield none } resolves to ?usize
  • new blk: / yield :blk label surface adds one label field to the AST Stmt; no new token (blk: is Identifier Colon, :blk is Colon Identifier). The parser carries a value if/for/while as a one-element block-init body (the same expr-invalid signal a value block uses)
  • no HIR/lowering change (like 18/19/20): a value-if/loop desugars in the checker to a mutable result slot (a poison-/fall-through-initialized local) that branches/iterations assign and that is read after the construct — the existing alloca-backed local flow. A yield :blk x desugars to slot = x; break, reusing the milestone-18 Break lowering. Each value-if branch is a build_value_block call; the value-loop reuses the ordinary .For/.While build via a peeled-body copy. HIR never holds a .Yield
  • errors: an if value without else; a branch/value-block not ending in yield; a value loop body without a trailing fall-through yield; a yield :blk with no matching value loop. The TODO "BAD" loops (unlabeled yield from inside an if, an unbound labeled loop) fall out of these naturally
  • follow-ups: a branch that early-returns instead of yielding, unwrap-if as a value source, and none-before-concrete typing in untyped loops are done in 20.6; labeled value blocks and yield/break to an outer loop are done in 20.7

20.6 value if/loop follow-ups (implemented; checker-only)

  • a value-if branch may end in yield or exit on every path (return/break/ continue) — r :: if (ok) { yield x } else { return -1 }. A non-terminating, non-yielding branch is rejected ("a value branch must end with 'yield' or exit on every path"). Checked via all_paths_exit on the built branch in emit_value_branch
  • unwrap-if as a value source: name :: if opt |v| { yield v * 2 } else { yield d }. emit_value_if gained an unwrap path mirroring the build-pass .If unwrap arm (captures + guard), each branch assigning the slot; the HIR .If carries the unwraps, which the existing lowering already handles. (The simple "unwrap or fallback" case is just orelsename :: opt orelse d — already a plain expression.)
  • untyped value loops pre-type their element from the first concrete (non-none) yield regardless of source order (a capture-scoped probe build, value_loop_element_type), so a none yielded before any concrete value still resolves the result to ?T
  • still checker-only; no HIR/lowering change

20.7 labels — value blocks + yield/break to an outer loop (implemented; first lowering change)

  • x :: blk: { …; yield :blk v } — a labeled value block (the disambiguated form of "an if/loop at the end of a block"; an unlabeled trailing if/loop stays ambiguous and is not a value source). yield :blk v exits the block with a value; every path must yield. Carries the same {T, none}?T typing, defer-capture, and reassignment forms as value loops
  • yield :outer v to an enclosing (non-innermost) value loop/block, plus plain break :L / continue :L to an enclosing labeled loop
  • a label now names a first-class exit target: label added to the HIR Stmt (on .While/.For/.Break/.Continue) and a new HIR .Block kind (lowers to its body + an exit label). The lowering's Loop_Ctx/State.loops became a label-keyed exit-target stack (is_loop distinguishes loops from value blocks; plain break/continue take the innermost loop, a labeled one searches by label). The checker tracks a loop_labels stack and a Yield_Target.defer_floor; a yield :L v desugars to slot = v; flush defers to L's body; break :L, reusing the milestone-18 break lowering — no new IR opcode, no emitter change
  • a labeled bare block as a plain statement is also exitable with break :blk (a HIR .Block break target; not a loop, so unlabeled break/continue and continue :blk skip it). The checker tracks a parallel loop_is_loop stack so labeled break reaches a loop or block while continue and unlabeled break/continue reach only the innermost loop
  • untyped block none-before-concrete typing now builds the block's leading (yield-free) statements first (a throwaway probe), so a first concrete yield :blk that references a block local still resolves the result to ?T
  • deferred (// ponytail:): the same none-before-concrete typing in an untyped block (or loop) whose concrete yield references a local declared past the first yield (annotate); same-label loop/block shadowing resolves innermost-wins
  1. unions and tagged unions (implemented; first pass — native untagged unions only; see below)
  • inspired by zig
  • # unions (untagged; named fields like a struct — Zig union members are always named)
    SomeStuff :: union {
        f float
        i int
        a Animal
    }
    
    # tagged unions (constrained to the backing enum) — see 21.5
    AnimalNameOrHeight :: union(Animal) {  # use just `enum` instead of `Animal` for unconstrained tagged union
        dog []u8
        cat []u8
        bird int
        lizard int
    }
    
  • this first pass ships native untagged unions with named fields. an untagged union is a carrier sized to its largest/most-aligned member with no runtime tag (a C union); reading a non-active field reinterprets the bytes (unsafe, like a Zig union {} in ReleaseFast) and an untagged union is not matchable. the TODO's original bare type-only spelling (union { float, int, Animal }) was dropped in favor of named fields to match Zig and to reuse the existing field-access path
  • the entire untagged-union backend already existed from the C-interop work (milestone 4): the .Union type kind, carrier-based LLVM layout (emit_types), size/alignment, keyed-literal construction Val{ field = value } (exactly one initializer; the active-field index rides in hir.Expr.integer), and field access (GEP-at-offset-0 reinterpret). none of these are gated on c_layout, so a native union flows through them unchanged. record-declaration validation (is_runtime_value per field) already covers native unions too
  • the change is front-end only: a new union keyword (token/lexer), and parse_union is just parse_struct parametrized with is_union=true routing through types.define_record (native-only, so c_layout=false and the no-body case errors). no types/checker/hir/ lower/llvm change (mirrors the minimal-footprint slices of 18/19/20)
  • deferred to 21.5: tagged unions (union(Enum) constrained + union(enum) inferred) with a runtime {tag, payload} layout, tag init at construction, and tag extraction — the real codegen work and the foundation for milestone 22 (match with payload unwrapping)

21.5. tagged unions with a runtime tag (implemented; see below)

  • union(Enum) (variant names constrained to an existing enum's members) and union(enum) (compiler-synthesized anonymous tag enum, one dense 0-based member per variant); both store the discriminant beside the payload as {tag, payload}
  • a tagged union is a first-class .Union type node whose child holds the tag enum (untagged unions and structs leave child INVALID). its fields are the variants (payload types), still looked up by name. union(enum) synthesizes its tag enum up front (types.enum_anonymous, variant names as members), so both forms converge on one representation
  • key reuse: the per-variant tag value is derivable from (union type, active variant index) — the variant's field name matches a member of the tag enum, whose value is the tag. so codegen computes the tag itself and the existing .Struct/.Field HIR (carrying type + active/field integer) is sufficient. no HIR/IR/lowering change (like 18/19/20); the delta is parser + types layout + one checker validation + three LLVM emit sites
  • runtime layout {tag, payload-carrier}: tag at offset 0, payload carrier at payload_offset = round_up(sizeof(tag), payload_align) (types.union_payload_offset, shared by size and the emitter). field access uses byte-offset GEPs, so offsets stay self-consistent
  • construction T{ variant = value } reuses the union-literal path and additionally stores the derived tag; payload read x.variant reuses field access, reading at the payload offset (unchecked reinterpret, Zig-style). safe tag dispatch + payload capture is milestone 22 (match)
  • changes: parse_struct parses union(...) (enum → synthesize; else an existing enum); types.define_record takes a tag param stored in child, plus is_tagged_union / union_tag_enum / union_payload_offset helpers and tagged size/alignment; the checker record-decl pass requires the tag to be an enum and each variant to name a member of it; the LLVM emitter lays out {tag, [pad], carrier, [pad]}, stores the tag in construction, and offsets payload field access
  • deferred to milestone 22 (match): safe tag dispatch + payload capture (match x { .bird |v| … }), exhaustiveness checking, and any first-class tag-read accessor
  1. match statements with tagged unions payload unwrapping

  2. error types

  • brolang should feature errors as values
  1. dynamic heap allocation
  • see below for direction
  • notes below are too big in scope for a first pass and the language is not mature enough to support it yet
  • this first pass should focus on just basic heap allocation, so we have something to work with

A word on multi-unwrap

Unwrap multiple optionals with and. This short-circuits: if the first optional is none, subsequent expressions are not evaluated.

name: ?[]u8 = get_name()
age: ?u8 = get_age()
if name and age |n, a| {
    # both n and a are guaranteed non-none here
    print("{s} is {d} years old", {n, a})
}

With guard clause on multiple values:

if name and hat |n, h : n == "Huginn" and h.brand == .gucci| {
    print("{s}'s got that drip\n", {n})
}

Parentheses around the expression are optional, but can aid readability when combined with guards:

# without parentheses
if name and hat |n, h : guard| { ... }

# with parentheses for clarity
if (name and hat) |n, h : guard| { ... }

A word on lazy / short-circuit evaluation

The and in multi-unwrap short-circuits left-to-right:

if get_name() and get_hat() |n, h| {
    # get_hat() is only called if get_name() returned non-none
}

This is important for avoiding unnecessary computation or side effects.

A word on ranges

Ranges represent a sequence of values, commonly used in for loops, and is itself a value type:

0..10      # exclusive: 0, 1, 2, ..., 9
0..=10     # inclusive: 0, 1, 2, ..., 10

Parenthesization rule: Each side of .. must be either a simple term (literal or identifier) or a parenthesized expression. This eliminates precedence ambiguity:

0..10           # OK: both sides are literals
0..n            # OK: both sides are simple
0..(n + 1)      # OK: complex expression is parenthesized
(a + 1)..(b - 1)  # OK: both sides parenthesized
# 0..n + 1      # ERROR: must parenthesize complex expressions

This rule keeps the grammar simple and forces clarity at the call site — no precedence rules to remember. Also, being a value type, ranges can be assigned to variables and passed around like any other value. Range bounds are evaluated once, must have compatible concrete integer types, and descending ranges are empty.

For-loop captures are immutable and scoped to the loop body. Sequence index captures are usize. Pointer capture uses |@item|; arrays must be passed by pointer (for example &items), while slices can be used directly. Sentinel elements are not included in iteration.

A word on distinct types

Distinct types are considered distinct from their backing type. They do not implicitly coerce to their backing type.

# distinct type
UserID :: distinct u32

# instantiate distinct type
my_id UserID :: UserID(42) # value must have the exact backing type

A word on enums

# standard enums
Animal :: enum {
    dog
    cat
    bird
    lizard
}

# enums with backing type
Nat :: enum(u8) { # in this case, a maximum of 256 values are possible
    one # default: implicitly starts from value 0
    two
    three
    four
    five
}

# enums with backing type with explicit associated values
# note: must not be jumbled (i.e. `first_val = 1` must come before `other_val = 2`), but is allowed to be discontiguous (i.e. `one = 1` can be followed by `three = 3` without `two = 2` in between)
Nat :: enum(u8) {
    one = 1
    two = 2
    three = 3
    # no four
    five = 5
}

# enums with backing type with semi-implicit associated values
Nat :: enum(u8) {
    one = 1 # starts from value 1
    two     # implicitly gets value 2
    three   # etc...
    four
    five
}

# using enums
dog_tag1 Animal :: Animal.dog
dog_tag2 Animal :: .dog # type inferred

Unbacked enums cannot assign explicit values. Backed enum values must be decimal integer literals, fit the backing type, and increase strictly; gaps are allowed.

Native enum types remain distinct from integers and from other enum types. They support == and !=, but not arithmetic, ordering, casts, or backing-value extraction.

C enums follow C/Zig import semantics rather than native enum semantics:

native :: import "native.h"
value native.Imported_Enum :: native.IMPORTED_ENUM_VALUE

The imported enum type is an alias of its target-selected C integer backing, and imported enumerators are package-level constants.

A word on multi-line strings

Multi-line strings use the ` character to mark each line. Content starts immediately after the backtick. Newlines between lines are implicit.

config =
    `# Database configuration
    `host = localhost
    `port = 5432
    `
    `[server]
    `address = 0.0.0.0

Key properties:

  • Content begins immediately after `
  • Newlines are automatically inserted between lines
  • Empty ` produces a blank line
  • No escape sequence processing (raw content)
  • No trailing newline after the last line

Only the leading ` is special; the rest is treated as raw content.

If you need a trailing newline, add an empty line at the end:

# No trailing newline
msg =
    `hello
    `world

# With trailing newline
msg =
    `hello
    `world
    `

Mixing multi-line strings with inline strings (using concatenation):

message = 
    "Header:\t" ++
    `more content here
    `even more content
    `
    ++ "Footer"

Formatting alternative (purely aesthetics/preference, no effect on program):

message =  "Header:\t" ++
           `more content here
           `even more content
           `
           ++ "Footer"

A word on yield

The yield keyword provides a value from a block to its enclosing expression and exits the block immediately — just as return exits a function, yield exits the enclosing scope. Code after a yield is unreachable, and the compiler flags it. This makes yield part of a consistent set of scope-exiting control flow: return exits a function, yield exits a block, break exits a loop, and continue skips to the next iteration.

It is used in scoped blocks, match arms, and catch handlers.

General rule: When a block needs to produce a value, single expressions yield implicitly while multi-statement blocks require explicit yield. This rule applies uniformly across the language:

# scoped block
data :: {
    result := compute()
    yield result
}

# match arms
label []u8 = match p {
    .high: "HIGH",          # single expression: implicit yield
    .low: {
        log("low priority")
        yield "LOW"         # block: explicit yield
    },
}

# catch handlers
data []u8 = read(path) catch default_data         # single expression: implicit
data []u8 = read(path) catch |e| {
    log(e)
    yield fallback_data                       # block: explicit yield
}

Yielding from if-statements and loops

Yielding from if-statements is possible with the constraint that all branches must resolve to the same yield type.

# yielding to a constant
result :: if a {
    yield 1
} else if b {
    yield 2
} else {
    yield 3
}

# yielding to a variable
result int = if a {
    yield 1
} else if b {
    yield 2
} else {
    yield 3
}

# ILLEGAL: branches with different yield types
result :: if a {
    yield 1
} else {
    yield Color{ r = 255, g = 0, b = 0 }
}

Yielding is also possible from loops with the same constraint.

# get active entity
active_ent_idx :: for 0..10 |i| blk: {
    if is_active(some_entity, i) yield :blk i
    yield none # fall-through: no active ent was found (this should imply a return type matching both the index value and `none`, meaning it should resolve to an optional in this case)

    # note that in this case, we have to use the `blk` label to yield from the correct scope.
    # otherwise, the yield should return directly from the if-statement's scope (which would be incorrect in this case).
}

# BAD: yield returned from if-statement, but no name binds it: should miscompile similar to unused return values from functions.
active_ent_idx :: for 0..10 |i| {
    if is_active(some_entity, i) yield i # bad
    yield none
}

# BAD: likewise for loops
for 0..10 |i| blk: { # bad, no name binds returned value
    if is_active(some_entity, i) yield :blk i
    yield none
}

A word on match statements

# matching on enums
match status {
    .ok: print("success")
    .error: print("failure")
    .pending: {
        log("still waiting")
        retry()
    }
}

# matching on integers and other values
match code {
    0: print("zero")
    1: print("one")
    2: print("two")
    else: print("other")  # needed - missing variants
}

Status :: enum { ok, error, pending }

status :: get_status() # returns a `Status`
match status {
    .ok: handle_ok()
    .error: handle_error()
    .pending: handle_pending()
    # no else needed - all variants covered
}

# matching on tagged unions
Result :: union(enum) {
    success Data
    failure struct {
        msg []u8
        code i32
    }
    pending void
}

match result {
    .success |data|: {  # use `|name|` to capture the variant's payload
        process(data)
    }
    .failure |info|: {
        print("error {d}: {s}", { info.code, info.msg })
    }
    .pending: {
        # void payload - no capture needed
        wait()
    }
}

# when a union variant has a `void` payload, omit the capture
Event :: union(enum) {
    click struct { x i32, y i32 }
    keypress KeyCode
    quit void
}

event :: get_event() # returns an `Event`
match event {
    .click |pos|: handle_click(pos.x, pos.y)
    .keypress |key|: handle_key(key)
    .quit: should_exit = true
}

# single-expression arms yield value implicitly
label :: match priority {
    .critical: "CRIT"
    .high:     "HIGH"
    .normal:   "NORM"
    .low:      " LOW"
}

# multi-statement arms use `yield`
message :: match code {
    0: "success"
    1: {
        log("warning encountered")
        yield "warning"
    }
    else: "unknown"
}

A word on memory allocation

(NOTE THAT SYNTAX MAY NOT MATCH BROLANG EXACTLY AND SHOULD BE TAKEN WITH A GRAIN OF SALT - INSPIRATION ONLY) (FURTHER, EXAMPLES ASSUME ARGUMENTS WITH DEFAULT VALUES AND COMPTIME POLYMORPHISM IN THE FORM OF GENERIC TYPE PARAMETERS - MONOMORPHISED)

Memory allocation in Brolang is designed to be explicit but not verbose. We reject the dogma that global state is inherently evil — allocators are a cross-cutting concern that nearly every function needs, making them a perfect candidate for sensible defaults.

Philosophy

┌─────────────────────────────────────────────────────────────┐
│  DESIGN PRINCIPLES                                          │
│                                                             │
│  1. No hidden magic: allocation calls are visible           │
│  2. Sensible defaults: thread-local heap for common cases   │
│  3. Explicit override: custom allocators when needed        │
│  4. Build-mode aware: different behavior for debug/release  │
│  5. Immutable defaults: no "action at a distance" bugs      │
│  6. Escaping allocations: caller provides allocator         │
└─────────────────────────────────────────────────────────────┘

The Default Heap Allocator

Brolang provides a thread-local global heap allocator that is:

  • Determined at compile time by build mode
  • Immutable at runtime — cannot be reconfigured
import "std/mem/heap"

process :: func(input []u8) u64 {
    # heap used for internal temporary work — does not escape
    temp := heap.alloc(u8, size: input.len * 2)
    defer heap.free(temp)

    # ... work with temp ...

    return compute_hash(temp)  # only the result escapes, not the allocation
}

The behavior of heap depends on build mode:

Build Mode Allocator Behavior
Debug Tracking allocator with leak detection
Release Fast allocator, zero overhead
ReleaseSafe Bounds-checking allocator

This is configured at compile time. You cannot change which allocator heap uses at runtime. This is intentional — it prevents bugs where memory allocated with one allocator is freed with another.

The Escaping Allocation Rule

If a function heap-allocates memory that escapes its scope — whether via the return value or via writes through mutable parameters — the function must accept an allocator parameter. The presence of an allocator parameter is the contract that says "heap memory escapes here, and you're responsible for it."

This rule makes ownership transfer visible at the function signature level. The caller never needs to read the function's implementation to know whether heap cleanup is involved:

import "std/mem"
import "std/mem/heap"

# Allocation escapes via return value — requires allocator
duplicate :: func(input []u8, allocator @mem.Allocator) []u8 {
    result := allocator.alloc(u8, size: input.len)
    mem.copy(result, input)
    return result  # caller manages this memory
}

# Allocation escapes via mutable parameter — requires allocator
init :: func(obj: @mut MyStruct, allocator: @mem.Allocator) void {
    obj.buffer = allocator.alloc(u8, size: 100)
    # caller now knows heap memory was written into obj
}

# No allocation escapes — no allocator needed
process :: func(input: []u8) u64 {
    temp := heap.alloc(u8, size: input.len)
    defer heap.free(temp)
    # ... work with temp ...
    return compute_hash(temp)
}

# No heap allocation at all — no allocator needed
reset :: func(obj: @mut MyStruct) void {
    obj.count = 0
}

main :: func() void {
    data := duplicate("hello", heap)
    defer heap.free(data)

    mut obj := MyStruct{ ... }
    init(&obj, heap)
    defer heap.free(obj.buffer)
}

Why this matters:

  • Without the rule, a function like init(obj: @mut MyStruct) void is ambiguous — did it heap-allocate into obj, or just set some fields to stack/static data? The caller has no way to know without reading the implementation.
  • With the rule, the allocator parameter is a clear signal: "this function produces heap memory that outlives its scope, and you are responsible for cleaning it up."
  • Internal allocations (temporary buffers, scratch space) use heap directly and are freed before the function returns. No allocator parameter needed, no burden on the caller.

The compiler enforces this rule. If a function heap-allocates memory that escapes without accepting an allocator parameter, the compiler emits an error.

Why Immutable Defaults?

Consider what would happen if you could reconfigure the default allocator:

# ❌ THIS IS NOT ALLOWED (and doesn't exist in Brolang)
mem.heap_set(my_custom_heap)

# Somewhere else in the codebase...
data := heap.alloc(u8, size: 100)

# Later, someone changes it again...
mem.heap_set(different_heap)

# Now who frees `data`? With which allocator?
heap.free(data)  # 💥 Wrong allocator - undefined behavior!

This is "action at a distance" — the behavior of heap.free() depends on what some unrelated code did earlier. By making heap immutable, Brolang guarantees:

Whatever you allocate with, you free with.

Custom Allocators

For specialized needs, you create explicit allocator instances. These are not global — you manage their lifetime and pass them where needed.

Arena Allocator: Fast bump allocation, bulk deallocation:

import "std/mem"
import "std/mem/heap"

process_file :: func(path: []u8, allocator: @mem.Allocator) !Data {
    # arena manages its own backing memory via heap
    arena := mem.Arena.init(heap, capacity: mem.megabytes(1))
    defer arena.deinit()

    # all temporary allocations from arena (fast bump allocation)
    file_contents := arena.alloc(u8, size: file_size)
    parsed := arena.alloc(ParsedData)         # size defaults to 1
    tokens := arena.alloc(Token, size: 1000)

    # ... process ...

    # escaping allocation uses the caller's allocator
    result := allocator.create(Data)
    mem.copy(result, parsed)

    return result
    # arena.deinit() frees all arena memory — no individual frees needed
}

Pool Allocator: O(1) fixed-size allocation, no fragmentation:

import "std/mem"
import "std/mem/heap"

EntitySystem :: struct {
    pool: mem.Pool(Entity),
}

init_entities :: func(allocator: @mem.Allocator) EntitySystem {
    return EntitySystem{
        pool = mem.Pool(Entity).init(allocator, capacity: 10_000),
    }
}

spawn :: func(sys: @mut EntitySystem) @Entity {
    return sys.pool.alloc()  # O(1), no fragmentation
}

despawn :: func(sys: @mut EntitySystem, entity: @Entity) void {
    sys.pool.free(entity)  # returned to pool for reuse
}

Passing Allocators to Functions

As described in the escaping allocation rule, when a function heap-allocates memory that escapes its scope, it must accept an allocator parameter. The caller decides which allocator to use:

import "std/mem"

# Function that uses caller's allocator
parse :: func(input: []u8, allocator: @mem.Allocator) !ParseResult {
    buffer := allocator.alloc(u8, size: input.len)
    defer allocator.free(buffer)
    
    # ... parse into buffer ...
    
    result := allocator.alloc(ParseResult)  # size defaults to 1
    return result
}

# Caller decides which allocator to use
main :: func() void {
    # use an arena for this parsing work
    arena := mem.Arena.init(heap, capacity: mem.kilobytes(64))
    defer arena.deinit()
    result := parse(input, &arena) catch |err| {
        # handle error
    }

    # or use a pool
    pool := mem.Pool(ParseResult).init(capacity: 100)
    defer pool.deinit()
    result := parse(input, &pool) catch |err| {
        # handle error
    }
}

Memory Allocation Summary

What How When to Use
heap.alloc(T, size: n) Thread-local global General purpose, 90% of cases
heap.create(T) Thread-local global Allocate single item
allocator.alloc(T, size: n) Caller-provided Escaping allocations (returned or written to caller's data)
arena.alloc(T, size: n) Explicit instance Temporary/scoped work, bulk free
pool.alloc() Explicit instance Many same-sized objects, O(1)

Note that heap satisfies the Allocator interface, so callers can pass heap as the allocator argument when they don't need a specialized allocator — which is most of the time.

The golden rule: Allocate and free with the same allocator. The type system helps enforce this — memory from heap can only be freed with heap, memory from your arena can only be freed with that arena. The escaping allocation rule ensures the caller always knows which allocator was used.

Compared to Other Languages

Language Approach Brolang's Advantage
C Hidden malloc, easy to mismatch Explicit allocator at call site
C++ Allocator templates, complex Simple, no template complexity
Rust Explicit everywhere, verbose Sensible defaults reduce noise
Zig Allocator parameter threading Only required for escaping allocations, not internal work
Odin Hidden context parameter Fully transparent, nothing hidden
Go Hidden GC Explicit control, no GC pauses

Brolang sits in a sweet spot: explicit enough to always know what's happening, convenient enough that you don't drown in boilerplate.