value if/loop follow-ups

This commit is contained in:
2026-06-27 23:24:36 +02:00
parent 61293a23e7
commit 3e54c6f9ad
4 changed files with 282 additions and 26 deletions
+33 -6
View File
@@ -358,17 +358,44 @@
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
- deferred to a later pass (`// ponytail:`): a value-if/loop nested as the *trailing*
statement of a value block (first pass is the direct `x :: if …`/`for …` form); a branch
that early-`return`s instead of yielding; unwrap-`if` (`if v |x| { yield … }`) as a value
source; `yield none` before any concrete yield in an untyped loop (annotate instead);
`yield :blk` to an outer (non-innermost) loop
- follow-ups: a branch that early-`return`s 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 20.7 (need labels in the IR)
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 `orelse` —
`name :: 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 labeled value blocks + `yield`/`break` to an outer loop (introduces labels in the IR)
- `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
- `yield :outer v` / labeled `break` to a non-innermost loop
- both need exit targets to carry a label: add `label` to HIR `.While`/`.For`/`.Break` and a
labeled `.Block`; generalize the lowering's `Loop_Ctx`/`State.loops` into an exit-target
stack keyed by label (plain `break`/`continue` stay innermost-only; a labeled `.Break`
searches by label; a labeled block pushes a non-loop target + an exit label after its body).
First lowering change in the `yield` line
21. unions and tagged unions
22. match statements with tagged unions payload unwrapping
23. dynamic heap allocation
23. error types
- brolang should feature errors as values
24. 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
+188 -12
View File
@@ -5063,27 +5063,102 @@ emit_value_if :: proc(
) -> bool {
checker := ctx.checker
if_stmt := checker.ast_module.statements[if_id]
// ponytail: an unwrap `if` (captures) as a value source is a later milestone.
condition := hir.INVALID_EXPR
guard := hir.INVALID_EXPR
unwraps: []hir.Conditional_Unwrap = nil
capture_start := len(ctx.locals^)
if len(if_stmt.captures) > 0 {
source.add(checker.diagnostics, if_stmt.span, "an unwrap 'if' cannot yet be used as a value")
ctx.problematic^ = true
return false
}
condition := build_expr(checker, if_stmt.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.BOOL, ctx.pkg, ctx.file)
if checker.module.exprs[condition].kind != .Invalid && !types.is_bool(checker.module.exprs[condition].type) {
id := source.add(checker.diagnostics, if_stmt.span, "'if' condition must be a bool")
condition = invalid_hir_expr(checker, if_stmt.span, id, types.BOOL)
ctx.problematic^ = true
// Unwrap value-if (`name :: if opt |v| { yield v } else { yield 0 }`): mirror the
// build-pass unwrap arm to bind captures + guard; each branch then assigns the slot
// like any other branch, and the HIR `.If` carries the unwraps (lowering handles it).
ast_operands: [dynamic]ast.Expr_Id
ast_operands.allocator = checker.allocator
flatten_conditional_unwrap_operands(checker.ast_module, if_stmt.expr, &ast_operands)
ok := true
if len(ast_operands) != len(if_stmt.captures) {
source.addf(checker.diagnostics, if_stmt.span,
"'if' unwrap has %d operands but %d captures", len(ast_operands), len(if_stmt.captures))
ok = false
}
values := make([]hir.Expr_Id, len(ast_operands), checker.allocator)
child_types := make([]types.Type, len(ast_operands), checker.allocator)
for operand, index in ast_operands {
child_types[index] = types.INVALID
v := build_expr(checker, operand, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file)
values[index] = v
vt := checker.module.exprs[v].type
if checker.module.exprs[v].kind == .Invalid {
ok = false
} else if !types.is_optional(vt, &checker.module.types) {
source.addf(checker.diagnostics, checker.ast_module.exprs[operand].span,
"'if' unwrap requires an optional value (operand %d)", index + 1)
ok = false
} else {
child_types[index] = types.child_type(vt, &checker.module.types)
}
}
unwrap_list: [dynamic]hir.Conditional_Unwrap
unwrap_list.allocator = checker.allocator
for capture, index in if_stmt.captures {
child := child_types[index] if index < len(child_types) else types.INVALID
local := hir.INVALID_LOCAL
if capture != checker.sink_symbol {
if _, dup := find_build_local(ctx.locals^[capture_start:], capture); dup {
source.add(checker.diagnostics, if_stmt.span, "'if' unwrap captures must have distinct names")
ok = false
}
local = hir.local_id(len(ctx.hir_locals^))
append(ctx.hir_locals, hir.Local{name=capture, type=child, mutable=false})
append(ctx.locals, Build_Local{name=capture, type=child, mutable=false, id=local})
}
if index < len(values) {
append(&unwrap_list, hir.Conditional_Unwrap{expr=values[index], local=local})
}
}
if if_stmt.guard != ast.INVALID_EXPR {
guard = build_expr(checker, if_stmt.guard, ctx.locals^[:], ctx.global_reads, ctx.calls, types.BOOL, ctx.pkg, ctx.file)
if checker.module.exprs[guard].kind == .Invalid {
ok = false
} else if !types.is_bool(checker.module.exprs[guard].type) {
source.add(checker.diagnostics, checker.ast_module.exprs[if_stmt.guard].span, "'if' unwrap guard must be a bool")
ok = false
}
}
delete(values, checker.allocator)
delete(child_types, checker.allocator)
delete(ast_operands)
if !ok {
resize(ctx.locals, capture_start)
delete(unwrap_list)
ctx.problematic^ = true
return false
}
unwraps = unwrap_list[:]
} else {
condition = build_expr(checker, if_stmt.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.BOOL, ctx.pkg, ctx.file)
if checker.module.exprs[condition].kind != .Invalid && !types.is_bool(checker.module.exprs[condition].type) {
id := source.add(checker.diagnostics, if_stmt.span, "'if' condition must be a bool")
condition = invalid_hir_expr(checker, if_stmt.span, id, types.BOOL)
ctx.problematic^ = true
}
}
// then-branch (unwrap captures, if any, are in scope here, then dropped before else).
then_body: [dynamic]hir.Stmt_Id
then_body.allocator = checker.allocator
if !emit_value_branch(ctx, &then_body, if_stmt.body, slot, slot_type, span) {
then_ok := emit_value_branch(ctx, &then_body, if_stmt.body, slot, slot_type, span)
resize(ctx.locals, capture_start)
if !then_ok {
delete(then_body)
delete(unwraps)
return false
}
if if_stmt.else_body == nil {
source.add(checker.diagnostics, if_stmt.span, "an 'if' used as a value must have an 'else' so every path yields")
delete(then_body)
delete(unwraps)
ctx.problematic^ = true
return false
}
@@ -5098,11 +5173,12 @@ emit_value_if :: proc(
if !branch_ok {
delete(then_body)
delete(else_body)
delete(unwraps)
return false
}
append(out, hir.stmt_id(len(checker.module.statements)))
append(&checker.module.statements, hir.Stmt{
kind = .If, span = if_stmt.span, expr = condition, guard = hir.INVALID_EXPR,
kind = .If, span = if_stmt.span, expr = condition, guard = guard, unwraps = unwraps,
then_body = then_body[:], else_body = else_body[:],
local = hir.INVALID_LOCAL, target = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC,
})
@@ -5112,6 +5188,8 @@ emit_value_if :: proc(
// emit_value_branch builds one branch of a value-if as a value block (leading stmts +
// trailing yield) and appends `slot = <value>`. The first branch of an untyped value-if
// fixes the slot type; later branches coerce to it (a mismatch is the "same type" error).
// A branch that does not yield is valid only if it exits on every path (return/break/
// continue) — it then produces no value and never reaches the slot read.
emit_value_branch :: proc(
ctx: ^Build_Ctx,
out: ^[dynamic]hir.Stmt_Id,
@@ -5121,6 +5199,27 @@ emit_value_branch :: proc(
span: source.Span,
) -> bool {
checker := ctx.checker
n := len(branch_stmts)
ends_in_yield := n > 0 &&
checker.ast_module.statements[branch_stmts[n - 1]].kind == .Yield &&
!symbol.is_valid(checker.ast_module.statements[branch_stmts[n - 1]].label)
if !ends_in_yield {
// Not a value block: only allowed if every path exits (e.g. `else { return -1 }`),
// in which case it contributes no value to the slot.
built := build_block(ctx, branch_stmts)
for s in built {
append(out, s)
}
terminates := all_paths_exit(&checker.module, built)
delete(built, checker.allocator)
if terminates {
return true
}
source.add(checker.diagnostics, span,
"a value branch must end with 'yield' or exit on every path (return/break/continue)")
ctx.problematic^ = true
return false
}
value, vtype := build_value_block(ctx, out, branch_stmts, slot_type^, span)
if checker.module.exprs[value].kind == .Invalid {
return false
@@ -5175,6 +5274,74 @@ resolve_loop_slot :: proc(ctx: ^Build_Ctx, target: ^Yield_Target, value: hir.Exp
return coerce_expr(checker, value, target.slot_type, span)
}
// first_concrete_yield_expr returns the AST expr of the first yield (source order) that is
// not the literal `none`, descending into `if`/block branches but not nested loops or value
// sources (whose yields belong to them). INVALID when the loop yields only `none`.
first_concrete_yield_expr :: proc(checker: ^Checker, stmts: []ast.Stmt_Id) -> ast.Expr_Id {
for id in stmts {
s := checker.ast_module.statements[id]
#partial switch s.kind {
case .Yield:
if s.expr != ast.INVALID_EXPR && checker.ast_module.exprs[s.expr].kind != .None {
return s.expr
}
case .If:
if e := first_concrete_yield_expr(checker, s.body); e != ast.INVALID_EXPR {
return e
}
if e := first_concrete_yield_expr(checker, s.else_body); e != ast.INVALID_EXPR {
return e
}
case .Block:
if e := first_concrete_yield_expr(checker, s.body); e != ast.INVALID_EXPR {
return e
}
}
}
return ast.INVALID_EXPR
}
// value_loop_element_type pre-types the element of an untyped value loop from its first
// concrete (non-`none`) yield, so a `none` yielded before any concrete value still resolves
// the result to `?T`. The loop's captures are bound temporarily for the probe and the probe
// expr is discarded; returns INVALID when the loop yields only `none`.
value_loop_element_type :: proc(ctx: ^Build_Ctx, loop_stmt: ast.Stmt) -> types.Type {
checker := ctx.checker
yield_expr := first_concrete_yield_expr(checker, loop_stmt.body)
if yield_expr == ast.INVALID_EXPR {
return types.INVALID
}
capture_start := len(ctx.locals^)
if loop_stmt.kind == .For {
// Mirror the `.For` arm's capture-type computation just enough to type the probe.
iterable := build_expr(checker, loop_stmt.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file)
iterable_type := checker.module.exprs[iterable].type
capture_type := types.INVALID
if types.is_range(iterable_type, &checker.module.types) {
capture_type = types.child_type(iterable_type, &checker.module.types)
} else if item, ok := sequence_item(iterable_type, &checker.module.types); ok {
capture_type = item.child
if loop_stmt.pointer_capture {
capture_type = types.pointer(&checker.module.types, item.child, item.mutable, false)
}
}
if symbol.is_valid(loop_stmt.name) {
id := hir.local_id(len(ctx.hir_locals^))
append(ctx.hir_locals, hir.Local{name=loop_stmt.name, type=capture_type, mutable=false})
append(ctx.locals, Build_Local{name=loop_stmt.name, type=capture_type, mutable=false, id=id})
}
if symbol.is_valid(loop_stmt.index_name) {
id := hir.local_id(len(ctx.hir_locals^))
append(ctx.hir_locals, hir.Local{name=loop_stmt.index_name, type=types.USIZE, mutable=false})
append(ctx.locals, Build_Local{name=loop_stmt.index_name, type=types.USIZE, mutable=false, id=id})
}
}
probe := build_expr(checker, yield_expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file)
result := checker.module.exprs[probe].type if checker.module.exprs[probe].kind != .Invalid else types.INVALID
resize(ctx.locals, capture_start)
return result
}
// build_value_loop turns a labeled `for/while ... blk: { … }` whose body ends in a
// fall-through `yield` (and may early-exit via `yield :blk x`) into a result slot:
// the fall-through value initializes the slot before the loop, each `yield :blk x`
@@ -5216,6 +5383,15 @@ build_value_loop :: proc(
slot_type = expected
slot = new_value_slot(ctx, slot_type)
result_optional = types.is_optional(slot_type, &checker.module.types)
} else if result_optional {
// Untyped loop that also yields `none`: pre-type the element from the first
// concrete yield (regardless of source order) so a `none` built before any
// concrete yield still resolves the result to `?T`.
elem := value_loop_element_type(ctx, loop_stmt)
if is_runtime_type(checker, elem) {
slot_type = types.optional(&checker.module.types, elem)
slot = new_value_slot(ctx, slot_type)
}
}
append(ctx.yield_targets, Yield_Target{
label = loop_stmt.label, slot = slot, slot_type = slot_type, result_optional = result_optional,
+9 -8
View File
@@ -2131,10 +2131,11 @@ yield_compiles_and_runs :: proc(t: ^testing.T) {
status := compiler_core.compile_package("examples/programs/yield", output)
testing.expect_value(t, status, 0)
state := run_executable(output)
// Value blocks (untyped/typed, defer-spill, reassignment) plus value if-statements
// (untyped/typed/reassign/else-if/defer) and value loops (a labeled `for` search
// yielding `?usize` on both the found and not-found paths, and a labeled `while`)
// together produce 42.
// Value blocks (untyped/typed, defer-spill, reassignment), value if-statements
// (untyped/typed/reassign/else-if/defer, a branch that `return`s instead of yielding,
// and an unwrap-`if` as a value source), `orelse`, and value loops (a labeled `for`
// search yielding `?usize` on the found/not-found paths, a labeled `while`, and an
// untyped loop that yields `none` before any concrete value) together produce 42.
testing.expect_value(t, state.exit_code, 42)
}
@@ -2179,9 +2180,9 @@ yield_misuse_is_diagnosed :: proc(t: ^testing.T) {
@(test)
yield_control_flow_is_diagnosed :: proc(t: ^testing.T) {
// Value if/loop misuse (milestone 20.5): an `if` value without an `else`; a
// branch that does not end in `yield`; a value loop whose body lacks a trailing
// fall-through `yield`; and a `yield :label` with no matching value loop.
// Value if/loop misuse: an `if` value without an `else`; a branch that neither
// yields nor exits on every path (milestone 20.6); a value loop whose body lacks a
// trailing fall-through `yield`; and a `yield :label` with no matching value loop.
text := `main :: func() i32 {
noelse :: if (true) {
yield 1
@@ -2218,7 +2219,7 @@ yield_control_flow_is_diagnosed :: proc(t: ^testing.T) {
stray_label := false
for diagnostic in diagnostics.items {
no_else = no_else || strings.contains(diagnostic.message, "an 'if' used as a value must have an 'else'")
branch_no_yield = branch_no_yield || strings.contains(diagnostic.message, "a value block must end with an explicit 'yield'")
branch_no_yield = branch_no_yield || strings.contains(diagnostic.message, "a value branch must end with 'yield' or exit on every path")
loop_no_yield = loop_no_yield || strings.contains(diagnostic.message, "a value loop's body must end with a 'yield'")
stray_label = stray_label || strings.contains(diagnostic.message, "no enclosing value loop is labeled 'stray'")
}
+52
View File
@@ -130,6 +130,50 @@ loop_while :: func() i32 {
return 2
}
# --- value if/loop follow-ups (milestone 20.6) -------------------------------
# A branch may exit on every path (here `return`) instead of yielding; the slot
# read after the `if` is only reached on the yielding path.
vif_return :: func(sel i32) i32 {
r :: if (sel == 0) {
yield 10
} else {
return 55 # exits the function instead of yielding
}
return r + 1 # only when sel == 0: 10 + 1 = 11
}
# unwrap-`if` as a value source: present -> transform, absent -> default.
vif_unwrap :: func(opt ?i32) i32 {
r :: if opt |v| {
yield v * 2
} else {
yield 99
}
return r
}
# The simple "unwrap or fallback" case is just `orelse` (already a plain expression).
orelse_value :: func(opt ?i32) i32 {
r :: opt orelse 7
return r
}
# Untyped value loop where `none` is yielded (in a labeled yield) before any
# concrete value: the element type still resolves to ?<i> from `yield :blk i`.
loop_none_first :: func() i32 {
r :: for 0..10 |i| blk: {
if (i > 100) yield :blk none
if (i * i > 40) yield :blk i # first concrete yield: i == 7
yield none
}
if r |found| {
if (found == 7) return 0
return 1
}
return 2
}
main :: func() i32 {
if (basic() != 42) return 101
if (typed() != 100) return 102
@@ -149,5 +193,13 @@ main :: func() i32 {
if (loop_none() != 0) return 114
if (loop_while() != 0) return 115
if (vif_return(0) != 11) return 116
if (vif_return(1) != 55) return 117
if (vif_unwrap(21) != 42) return 118
if (vif_unwrap(none) != 99) return 119
if (orelse_value(5) != 5) return 120
if (orelse_value(none) != 7) return 121
if (loop_none_first() != 0) return 122
return 42
}