From 300791052693c49b9787cb948d79a6517134cf69 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 28 Jun 2026 09:07:09 +0200 Subject: [PATCH] labeled block statements --- TODO.md | 13 +++- compiler/checker/checker.odin | 118 +++++++++++++++++++++++++++---- compiler/parser/parser.odin | 12 +++- compiler_tests.odin | 15 +++- examples/programs/yield/main.bro | 44 ++++++++++++ 5 files changed, 181 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index 2612440..469f781 100644 --- a/TODO.md +++ b/TODO.md @@ -391,9 +391,16 @@ 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** - - deferred (`// ponytail:`): a labeled bare block as a *plain statement* with `break :blk`; - `none`-before-concrete typing in an untyped block whose first concrete yield references a - block local (annotate instead) + - 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 21. unions and tagged unions diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index a0b170d..2c8679f 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -95,9 +95,12 @@ Build_Ctx :: struct { // only see loops opened within the defer (those past `loop_floor`). defers: ^[dynamic][]hir.Stmt_Id, loop_defer_starts: ^[dynamic]int, - // Parallel to `loop_defer_starts`: the label of each enclosing loop (INVALID when - // unlabeled), so a `break :L` / `continue :L` can target an outer labeled loop. + // Parallel to `loop_defer_starts`: the label of each enclosing break target (INVALID + // when unlabeled), so a `break :L` / `continue :L` can target an outer one. A labeled + // block statement is a break target too; `loop_is_loop` distinguishes loops (which + // `continue` and unlabeled `break`/`continue` target) from value/labeled blocks. loop_labels: ^[dynamic]symbol.Id, + loop_is_loop: ^[dynamic]bool, defer_depth: int, loop_floor: int, } @@ -4603,7 +4606,9 @@ build_block :: proc( } append(ctx.loop_defer_starts, len(ctx.defers^)) append(ctx.loop_labels, statement.label) + append(ctx.loop_is_loop, true) loop_body := build_block(ctx, statement.body) + pop(ctx.loop_is_loop) pop(ctx.loop_labels) pop(ctx.loop_defer_starts) update := hir.INVALID_STMT @@ -4700,7 +4705,9 @@ build_block :: proc( } append(ctx.loop_defer_starts, len(ctx.defers^)) append(ctx.loop_labels, statement.label) + append(ctx.loop_is_loop, true) loop_body := build_block(ctx, statement.body, capture_start) + pop(ctx.loop_is_loop) pop(ctx.loop_labels) pop(ctx.loop_defer_starts) resize(ctx.locals, capture_start) @@ -4740,14 +4747,22 @@ build_block :: proc( // hides the loops opened outside the defer. target_index := -1 if symbol.is_valid(statement.label) { + // `break :L` targets a labeled loop or block; `continue :L` only a loop. for i := len(ctx.loop_labels^) - 1; i >= ctx.loop_floor; i -= 1 { - if ctx.loop_labels^[i] == statement.label { + if ctx.loop_labels^[i] == statement.label && + (ctx.loop_is_loop^[i] || statement.kind == .Break) { + target_index = i + break + } + } + } else { + // Unlabeled `break`/`continue` targets the innermost loop, skipping blocks. + for i := len(ctx.loop_defer_starts^) - 1; i >= ctx.loop_floor; i -= 1 { + if ctx.loop_is_loop^[i] { target_index = i break } } - } else if len(ctx.loop_defer_starts^) > ctx.loop_floor { - target_index = len(ctx.loop_defer_starts^) - 1 } if target_index < 0 { keyword := "break" if statement.kind == .Break else "continue" @@ -4776,6 +4791,25 @@ build_block :: proc( local = hir.INVALID_LOCAL, diagnostic = source.INVALID_DIAGNOSTIC, }) case .Block: + if symbol.is_valid(statement.label) { + // A labeled block statement (`blk: { … break :blk … }`): a break target + // with an exit-label boundary, built as a HIR `.Block`. Not a loop, so + // unlabeled `break`/`continue` and `continue :blk` skip it. + append(ctx.loop_defer_starts, len(ctx.defers^)) + append(ctx.loop_labels, statement.label) + append(ctx.loop_is_loop, false) + built := build_block(ctx, statement.body) + pop(ctx.loop_is_loop) + pop(ctx.loop_labels) + pop(ctx.loop_defer_starts) + append(&body, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Block, span = statement.span, label = statement.label, + then_body = built, local = hir.INVALID_LOCAL, target = hir.INVALID_EXPR, + diagnostic = source.INVALID_DIAGNOSTIC, + }) + continue + } // A bare `{ ... }` scope: build it (its own locals/defers are scoped by // the recursive call) and splice its statements in. block := build_block(ctx, statement.body) @@ -5364,6 +5398,62 @@ value_loop_element_type :: proc(ctx: ^Build_Ctx, loop_stmt: ast.Stmt) -> types.T return result } +// stmt_contains_yield reports whether a statement contains a `yield` anywhere within it +// (recursing through if/block/loop bodies). Used to stop the leading probe build before any +// statement that yields (the block's yield target is not pushed during the probe). +stmt_contains_yield :: proc(checker: ^Checker, id: ast.Stmt_Id) -> bool { + s := checker.ast_module.statements[id] + #partial switch s.kind { + case .Yield: + return true + case .If, .For, .While, .Block: + for sub in s.body { + if stmt_contains_yield(checker, sub) { + return true + } + } + for sub in s.else_body { + if stmt_contains_yield(checker, sub) { + return true + } + } + } + return false +} + +// block_element_type pre-types an untyped value block's element from its first concrete +// `yield :blk`, building the block's leading (yield-free) statements first so the probe can +// reference block locals declared before the first yield. The leading build is a throwaway +// (its scope is restored). INVALID when there is no concrete yield, or the concrete yield +// references a local only in scope past the first yield (annotate the binding instead). +block_element_type :: proc(ctx: ^Build_Ctx, block_stmts: []ast.Stmt_Id) -> types.Type { + checker := ctx.checker + concrete := first_concrete_yield_expr(checker, block_stmts) + if concrete == ast.INVALID_EXPR { + return types.INVALID + } + lead_end := len(block_stmts) + for id, i in block_stmts { + if stmt_contains_yield(checker, id) { + lead_end = i + break + } + } + scope_start := len(ctx.locals^) + defer_start := len(ctx.defers^) + lead := build_block(ctx, block_stmts[:lead_end], close = false) + delete(lead, checker.allocator) + probe := build_expr(checker, concrete, 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 + // Discard the throwaway leading build's scope (its hir stmts/locals are dead but stable). + for i := defer_start; i < len(ctx.defers^); i += 1 { + delete(ctx.defers^[i], checker.allocator) + } + resize(ctx.defers, defer_start) + resize(ctx.locals, scope_start) + return result +} + // build_value_labeled_block turns `x :: blk: { …; yield :blk v }` into a result slot each // `yield :blk` assigns (via the build_block `.Yield` desugar → `slot = v; break :blk`), then // reads it after the block. Every path must yield (or otherwise exit); HIR holds a `.Block` @@ -5388,14 +5478,12 @@ build_value_labeled_block :: proc( result_optional = types.is_optional(slot_type, &checker.module.types) } else if result_optional { // Untyped block that also yields `none`: pre-type the element from the first - // concrete yield (a block has no captures, so a capture-free probe suffices). - elem := first_concrete_yield_expr(checker, block_stmts) - if elem != ast.INVALID_EXPR { - probe := build_expr(checker, elem, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file) - if checker.module.exprs[probe].kind != .Invalid { - slot_type = types.optional(&checker.module.types, checker.module.exprs[probe].type) - slot = new_value_slot(ctx, slot_type) - } + // concrete yield (regardless of source order) so a `none` yielded first still + // resolves the result to `?T`. + elem := block_element_type(ctx, block_stmts) + 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{ @@ -5722,6 +5810,8 @@ build_function :: proc(checker: ^Checker, id: Spec_Id) { loop_defer_starts.allocator = checker.allocator loop_labels: [dynamic]symbol.Id loop_labels.allocator = checker.allocator + loop_is_loop: [dynamic]bool + loop_is_loop.allocator = checker.allocator yield_targets: [dynamic]Yield_Target yield_targets.allocator = checker.allocator ctx := Build_Ctx{ @@ -5738,6 +5828,7 @@ build_function :: proc(checker: ^Checker, id: Spec_Id) { defers = &defers, loop_defer_starts = &loop_defer_starts, loop_labels = &loop_labels, + loop_is_loop = &loop_is_loop, yield_targets = &yield_targets, } block := build_block(&ctx, function.body) @@ -5789,6 +5880,7 @@ build_function :: proc(checker: ^Checker, id: Spec_Id) { delete(defers) delete(loop_defer_starts) delete(loop_labels) + delete(loop_is_loop) delete(yield_targets) delete(locals) } diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index dee953d..ffcf4a5 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -1058,14 +1058,16 @@ parse_loop_control :: proc(parser: ^Parser, kind: ast.Stmt_Kind) -> ast.Stmt_Id } // A bare `{ ... }` introduces a nested scope. Locals declared inside are not -// visible after it, and any `defer`s inside it run at the closing brace. -parse_block_statement :: proc(parser: ^Parser) -> ast.Stmt_Id { +// visible after it, and any `defer`s inside it run at the closing brace. A labeled +// `blk: { ... }` can be exited early with `break :blk`. +parse_block_statement :: proc(parser: ^Parser, label := symbol.INVALID) -> ast.Stmt_Id { start := current(parser).span // the '{' body := parse_block(parser) id := ast.stmt_id(len(parser.module.statements)) append(&parser.module.statements, ast.Stmt{ kind=.Block, span=start, + label=label, body=body, expr=ast.INVALID_EXPR, update=ast.INVALID_STMT, @@ -1159,6 +1161,12 @@ parse_statement :: proc(parser: ^Parser) -> ast.Stmt_Id { if current(parser).kind == .Left_Brace { return parse_block_statement(parser) } + // `blk: { ... }` is a labeled block statement (exitable via `break :blk`). At + // statement start, `Identifier ':'` (a single colon, not `::`) opens one. + if current(parser).kind == .Identifier && peek(parser).kind == .Colon { + label := parse_optional_loop_label(parser) + return parse_block_statement(parser, label) + } if current(parser).kind == .Identifier || current(parser).kind == .Underscore { start_cursor := parser.cursor diff --git a/compiler_tests.odin b/compiler_tests.odin index a80d9c3..56a6f6a 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2133,8 +2133,9 @@ yield_compiles_and_runs :: proc(t: ^testing.T) { state := run_executable(output) // Value blocks, value if-statements (incl. `return` branches and unwrap-`if`), // `orelse`, value loops, labeled value blocks (`blk: { … yield :blk v }`, incl. - // defer-capture and `{T,none}` → optional), and outer-loop control (`yield :outer v` - // and `break :outer` from an inner loop) together produce 42. + // defer-capture, `{T,none}` → optional, and `none` before a concrete yield that uses a + // block local), outer-loop control (`yield :outer v` / `break :outer`), and labeled + // block statements exited via `break :blk` together produce 42. testing.expect_value(t, state.exit_code, 42) } @@ -2182,7 +2183,8 @@ yield_control_flow_is_diagnosed :: proc(t: ^testing.T) { // Value if/loop/block misuse: an `if` value without an `else`; a branch that neither // yields nor exits on every path (20.6); a value loop whose body lacks a trailing // fall-through `yield`; a `yield :label` with no matching value loop; a labeled value - // block that does not yield on every path, and a `break :label` naming no loop (20.7). + // block that does not yield on every path; a `break :label` naming no loop; and a + // `continue :label` targeting a block (not a loop). text := `main :: func() i32 { noelse :: if (true) { yield 1 @@ -2205,6 +2207,9 @@ yield_control_flow_is_diagnosed :: proc(t: ^testing.T) { for 0..10 |m| { break :nope } + scope: { + continue :scope + } return noelse + badbranch + noloopyield + noblockyield } ` @@ -2226,6 +2231,7 @@ yield_control_flow_is_diagnosed :: proc(t: ^testing.T) { stray_label := false block_no_yield := false bad_break_label := false + continue_block := 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 branch must end with 'yield' or exit on every path") @@ -2233,6 +2239,8 @@ yield_control_flow_is_diagnosed :: proc(t: ^testing.T) { stray_label = stray_label || strings.contains(diagnostic.message, "no enclosing value loop or block is labeled 'stray'") block_no_yield = block_no_yield || strings.contains(diagnostic.message, "a labeled value block must 'yield' on every path") bad_break_label = bad_break_label || strings.contains(diagnostic.message, "no enclosing loop is labeled 'nope'") + // `continue :scope` targets a labeled block, which is not a loop. + continue_block = continue_block || strings.contains(diagnostic.message, "no enclosing loop is labeled 'scope'") } testing.expect(t, no_else) testing.expect(t, branch_no_yield) @@ -2240,6 +2248,7 @@ yield_control_flow_is_diagnosed :: proc(t: ^testing.T) { testing.expect(t, stray_label) testing.expect(t, block_no_yield) testing.expect(t, bad_break_label) + testing.expect(t, continue_block) } @(test) diff --git a/examples/programs/yield/main.bro b/examples/programs/yield/main.bro index 9e90199..58521b1 100644 --- a/examples/programs/yield/main.bro +++ b/examples/programs/yield/main.bro @@ -240,6 +240,45 @@ break_outer :: func() i32 { return count # 5 } +# A labeled block *statement* (not a value source): `break :blk` exits it early. +stmt_block :: func(early i32) i32 { + x i32 = 0 + blk: { + x = 1 + if (early == 1) break :blk + x = 2 + } + return x # early == 1 -> 1, else 2 +} + +# `break :search` escapes a nested loop and the block in one jump; the block's +# defer still runs on the way out. +stmt_block_escape :: func() i32 { + hits i32 = 0 + search: { + defer hits += 1000 + for 0..10 |i| { + hits += 1 + if (i == 3) break :search + } + hits += 100 # skipped by break :search + } + return hits # 4 + 1000 (defer) = 1004 +} + +# Item B: a `none` yielded before a concrete `yield :blk` that references a block local. +lblock_local :: func() i32 { + r :: blk: { + val :: 9 + if (false) yield :blk none + yield :blk val + } + if r |v| { + return v + } + return -1 +} + main :: func() i32 { if (basic() != 42) return 101 if (typed() != 100) return 102 @@ -276,5 +315,10 @@ main :: func() i32 { if (yield_outer(99) != -1) return 129 if (break_outer() != 5) return 130 + if (stmt_block(1) != 1) return 131 + if (stmt_block(0) != 2) return 132 + if (stmt_block_escape() != 1004) return 133 + if (lblock_local() != 9) return 134 + return 42 }