From 81ae939bf01bd8bcbbd3d66b0681ef27868fbadf Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Fri, 26 Jun 2026 23:43:57 +0200 Subject: [PATCH] `break` and `continue` in loops --- TODO.md | 61 +++++++++++++++++++++-- compiler/ast/ast.odin | 2 + compiler/checker/checker.odin | 50 ++++++++++++++++++- compiler/hir/hir.odin | 2 + compiler/lexer/lexer.odin | 2 + compiler/llvm/llvm.odin | 21 +++++--- compiler/lower/lower.odin | 45 +++++++++++++++++ compiler/parser/parser.odin | 23 ++++++++- compiler/token/token.odin | 2 + compiler_tests.odin | 60 ++++++++++++++++++++++ examples/programs/break_continue/main.bro | 58 +++++++++++++++++++++ 11 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 examples/programs/break_continue/main.bro diff --git a/TODO.md b/TODO.md index e1ef2a5..6662207 100644 --- a/TODO.md +++ b/TODO.md @@ -269,13 +269,34 @@ - `++` concatenation (the spec's "mixing" examples) is a separate, unimplemented operator and is out of scope here -18. add `defer` statement (inspired by zig) +18. 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 -19. unions and tagged unions +19. add `defer` statement (inspired by zig) + - now unblocked: `defer` reuses the loop-target stack and loop tracking added in + milestone 18 to flush deferred statements on `break`/`continue` exits too -20. match statements with tagged unions payload unwrapping +20. add `yield` statement (see below) -21. dynamic heap allocation +21. unions and tagged unions + +22. match statements with tagged unions payload unwrapping + +23. 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 @@ -478,6 +499,38 @@ message = "Header:\t" ++ ++ "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 +} +``` + ## A word on memory allocation (NOTE THAT SYNTAX MAY NOT MATCH BROLANG EXACTLY AND SHOULD BE TAKEN WITH A GRAIN OF SALT - INSPIRATION ONLY) diff --git a/compiler/ast/ast.odin b/compiler/ast/ast.odin index a4f5b73..e3194f3 100644 --- a/compiler/ast/ast.odin +++ b/compiler/ast/ast.odin @@ -129,6 +129,8 @@ Stmt_Kind :: enum u8 { If, While, For, + Break, + Continue, } Assignment_Op :: enum u8 { diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index 0597ac9..042bdcc 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -68,6 +68,9 @@ Build_Ctx :: struct { global_reads: ^[dynamic]hir.Global_Id, calls: ^[dynamic]hir.Function_Id, problematic: ^bool, + // Number of enclosing loops being built. `break`/`continue` are only valid + // when this is > 0; bumped around loop-body builds in `build_block`. + loop_depth: int, } Constant_Kind :: enum { @@ -734,6 +737,7 @@ mark_block_imports_used :: proc(checker: ^Checker, statements: []ast.Stmt_Id, fi case .For: mark_expr_imports_used(checker, statement.expr, file) mark_block_imports_used(checker, statement.body, file) + case .Break, .Continue: case .Invalid: } } @@ -4439,7 +4443,9 @@ build_block :: proc( condition = invalid_hir_expr(checker, statement.span, id, types.BOOL) ctx.problematic^ = true } + ctx.loop_depth += 1 loop_body := build_block(ctx, statement.body) + ctx.loop_depth -= 1 update := hir.INVALID_STMT if statement.update != ast.INVALID_STMT { update_ast := [1]ast.Stmt_Id{statement.update} @@ -4531,7 +4537,9 @@ build_block :: proc( append(ctx.locals, Build_Local{name=statement.index_name, type=types.USIZE, mutable=false, id=index_local}) } } + ctx.loop_depth += 1 loop_body := build_block(ctx, statement.body, capture_start) + ctx.loop_depth -= 1 resize(ctx.locals, capture_start) append(&body, hir.stmt_id(len(checker.module.statements))) @@ -4562,6 +4570,24 @@ build_block :: proc( }) ctx.problematic^ = true } + case .Break, .Continue: + if ctx.loop_depth == 0 { + keyword := "break" if statement.kind == .Break else "continue" + id := source.addf(checker.diagnostics, statement.span, "'%s' outside of a loop", keyword) + append(&body, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Trap, span = statement.span, expr = hir.INVALID_EXPR, + local = hir.INVALID_LOCAL, diagnostic = id, + }) + ctx.problematic^ = true + continue + } + append(&body, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Break if statement.kind == .Break else .Continue, + span = statement.span, expr = hir.INVALID_EXPR, + local = hir.INVALID_LOCAL, diagnostic = source.INVALID_DIAGNOSTIC, + }) case .Invalid: append(&body, hir.stmt_id(len(checker.module.statements))) append(&checker.module.statements, hir.Stmt{ @@ -4593,9 +4619,12 @@ all_paths_return :: proc(module: ^hir.Module, stmts: []hir.Stmt_Id) -> bool { return true } case .While: + // A literal `while true` makes the end of the block unreachable — + // unless its body can `break` out of this loop. if statement.expr != hir.INVALID_EXPR && int(statement.expr) < len(module.exprs) { condition := module.exprs[statement.expr] - if condition.kind == .Bool && condition.integer != 0 { + if condition.kind == .Bool && condition.integer != 0 && + !loop_body_breaks(module, statement.then_body) { return true } } @@ -4604,6 +4633,25 @@ all_paths_return :: proc(module: ^hir.Module, stmts: []hir.Stmt_Id) -> bool { return false } +// Reports whether `stmts` contains a `break` that targets the enclosing loop: +// a `.Break` at this level or inside `if`/`else` branches counts, but a `break` +// inside a nested `.While`/`.For` targets that inner loop, so we do not descend. +loop_body_breaks :: proc(module: ^hir.Module, stmts: []hir.Stmt_Id) -> bool { + for id in stmts { + statement := module.statements[id] + #partial switch statement.kind { + case .Break: + return true + case .If: + if loop_body_breaks(module, statement.then_body) || + loop_body_breaks(module, statement.else_body) { + return true + } + } + } + return false +} + build_function :: proc(checker: ^Checker, id: Spec_Id) { spec := checker.specs[id] function := checker.ast_module.functions[spec.template] diff --git a/compiler/hir/hir.odin b/compiler/hir/hir.odin index 13fda6c..43cbbf9 100644 --- a/compiler/hir/hir.odin +++ b/compiler/hir/hir.odin @@ -154,6 +154,8 @@ Stmt_Kind :: enum u8 { If, While, For, + Break, + Continue, } Assignment_Op :: enum u8 { diff --git a/compiler/lexer/lexer.odin b/compiler/lexer/lexer.odin index fb76051..0360951 100644 --- a/compiler/lexer/lexer.odin +++ b/compiler/lexer/lexer.odin @@ -32,6 +32,8 @@ keyword_kind :: proc(text: string) -> token.Kind { case "if": return .Keyword_If case "while": return .Keyword_While case "for": return .Keyword_For + case "break": return .Keyword_Break + case "continue": return .Keyword_Continue case "else": return .Keyword_Else case "true": return .Keyword_True case "false": return .Keyword_False diff --git a/compiler/llvm/llvm.odin b/compiler/llvm/llvm.odin index 0d1379c..cd3acf2 100644 --- a/compiler/llvm/llvm.odin +++ b/compiler/llvm/llvm.odin @@ -652,12 +652,18 @@ emit_instruction_stream :: proc( sret_name := "", ) -> ir.Instruction_Id { return_value := ir.INVALID_INSTRUCTION - after_return := false + // Set after any terminator (`ret`, `br`, conditional `br`). Code reachable + // only by falling off a terminator is dead; it needs a fresh label to form a + // well-formed basic block — unless the next instruction is already a `.Label`, + // which opens its own block (the normal terminator-then-label sequence). + after_terminator := false for instruction, instruction_index in instructions { instruction_id := ir.instruction_id(instruction_index) - if after_return { - fmt.sbprintf(&emitter.builder, "recover_after_return_%d:\n", instruction_index) - after_return = false + if after_terminator { + if instruction.op != .Label { + fmt.sbprintf(&emitter.builder, "recover_after_return_%d:\n", instruction_index) + } + after_terminator = false } switch instruction.op { case .Param, .Const: @@ -1662,14 +1668,17 @@ emit_instruction_stream :: proc( fmt.sbprintf(&emitter.builder, "bro_block_%d:\n", instruction.integer) case .Br: fmt.sbprintf(&emitter.builder, " br label %%bro_block_%d\n", instruction.integer) + after_terminator = true case .Cond_Br: if !valid_value(instructions, instruction.a, types.BOOL, &emitter.module.types) { fmt.sbprintf(&emitter.builder, " br label %%bro_block_%d\n", u32(instruction.target)) + after_terminator = true continue } strings.write_string(&emitter.builder, " br i1 ") write_operand(&emitter.builder, instructions, instruction.a, types.BOOL, &emitter.module.types) fmt.sbprintf(&emitter.builder, ", label %%bro_block_%d, label %%bro_block_%d\n", instruction.integer, u32(instruction.target)) + after_terminator = true case .Trap: message := diagnostic_message(emitter, instruction.diagnostic, instruction.span, "invalid recovered source") emit_trap_call(emitter, message) @@ -1703,7 +1712,7 @@ emit_instruction_stream :: proc( write_operand(&emitter.builder, instructions, instruction.a, function.result, &emitter.module.types) strings.write_string(&emitter.builder, "\n") } - after_return = true + after_terminator = true case .Return_Void: if global_initializer { continue @@ -1713,7 +1722,7 @@ emit_instruction_stream :: proc( } else { strings.write_string(&emitter.builder, " ret void\n") } - after_return = true + after_terminator = true } } return return_value diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index 68660b9..ea2c71f 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -16,10 +16,19 @@ State :: struct { func_locals: []hir.Local, func_result: types.Type, expr_stack: [dynamic]Lower_Expr_Frame, + // Innermost-last stack of enclosing loop targets for `break`/`continue`. + loops: [dynamic]Loop_Ctx, next_label: i64, allocator: mem.Allocator, } +// `break` branches to `exit_lbl`; `continue` branches to `continue_lbl` (the +// loop's update/latch, which runs the update clause then re-tests the condition). +Loop_Ctx :: struct { + exit_lbl: i64, + continue_lbl: i64, +} + fresh_label :: proc(state: ^State) -> i64 { id := state.next_label state.next_label += 1 @@ -742,6 +751,18 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) { diagnostic=source.INVALID_DIAGNOSTIC, }) } + case .Break, .Continue: + // The checker guarantees these only appear inside a loop, so the + // stack is non-empty; guard defensively regardless. + if len(state.loops) > 0 { + target := state.loops[len(state.loops)-1] + label := target.exit_lbl if statement.kind == .Break else target.continue_lbl + append_instruction(state, ir.Instruction{ + op=.Br, span=statement.span, type=types.VOID, integer=label, + target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } case .Expression, .Sink: _ = lower_expr(state, statement.expr) case .Trap: @@ -919,7 +940,9 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) { target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, }) + append(&state.loops, Loop_Ctx{exit_lbl=exit_lbl, continue_lbl=update_lbl}) lower_statements(state, statement.then_body) + pop(&state.loops) append_instruction(state, ir.Instruction{ op=.Br, span=statement.span, type=types.VOID, integer=update_lbl, target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, @@ -1043,7 +1066,25 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) { target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, }) + // `continue` rejoins the normal end-of-iteration path (via a fresh + // label before the bounds/overflow guard) rather than jumping straight + // to the increment, so it behaves exactly like falling off the body — + // e.g. `for 0..=255 |b: u8| { ... continue }` exits cleanly instead of + // overflowing the increment on the final element. + continue_lbl := fresh_label(state) + append(&state.loops, Loop_Ctx{exit_lbl=exit_lbl, continue_lbl=continue_lbl}) lower_statements(state, statement.then_body) + pop(&state.loops) + append_instruction(state, ir.Instruction{ + op=.Br, span=statement.span, type=types.VOID, integer=continue_lbl, + target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + append_instruction(state, ir.Instruction{ + op=.Label, span=statement.span, type=types.VOID, integer=continue_lbl, + target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) after_body := append_instruction(state, ir.Instruction{ op=.Load, span=statement.span, type=child, target=ir.INVALID_REF, a=current_slot, b=ir.INVALID_INSTRUCTION, @@ -1216,7 +1257,9 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) { target=ir.INVALID_REF, a=capture_slot, b=captured, diagnostic=source.INVALID_DIAGNOSTIC, }) + append(&state.loops, Loop_Ctx{exit_lbl=exit_lbl, continue_lbl=update_lbl}) lower_statements(state, statement.then_body) + pop(&state.loops) append_instruction(state, ir.Instruction{ op=.Br, span=statement.span, type=types.VOID, integer=update_lbl, target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, @@ -1267,10 +1310,12 @@ lower_body :: proc(hir_module: ^hir.Module, function: hir.Function, allocator: m } state.instructions.allocator = allocator state.expr_stack.allocator = allocator + state.loops.allocator = allocator defer { delete(state.local_values, allocator) delete(state.local_slots, allocator) delete(state.expr_stack) + delete(state.loops) } for _, index in state.local_values { state.local_values[index] = ir.INVALID_INSTRUCTION diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index dd93a21..585399f 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -986,6 +986,21 @@ parse_return :: proc(parser: ^Parser) -> ast.Stmt_Id { return id } +// `break` / `continue` carry no value and target the innermost loop; the +// checker rejects them outside a loop. +parse_loop_control :: proc(parser: ^Parser, kind: ast.Stmt_Kind) -> ast.Stmt_Id { + marker := advance(parser) // consume 'break' / 'continue' + id := ast.stmt_id(len(parser.module.statements)) + append(&parser.module.statements, ast.Stmt{ + kind=kind, + span=marker.span, + expr=ast.INVALID_EXPR, + update=ast.INVALID_STMT, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + return id +} + starts_declared_type :: proc(parser: ^Parser) -> bool { if current(parser).kind != .Left_Bracket { return is_type_token(current(parser).kind) @@ -1024,6 +1039,12 @@ parse_statement :: proc(parser: ^Parser) -> ast.Stmt_Id { if current(parser).kind == .Keyword_For { return parse_for(parser) } + if current(parser).kind == .Keyword_Break { + return parse_loop_control(parser, .Break) + } + if current(parser).kind == .Keyword_Continue { + return parse_loop_control(parser, .Continue) + } if current(parser).kind == .Identifier || current(parser).kind == .Underscore { start_cursor := parser.cursor @@ -1312,7 +1333,7 @@ parse_while_update :: proc(parser: ^Parser) -> ast.Stmt_Id { statement := &parser.module.statements[update] switch statement.kind { case .Assignment, .Expression: - case .Invalid, .Declaration, .Return, .If, .While, .For: + case .Invalid, .Declaration, .Return, .If, .While, .For, .Break, .Continue: diagnostic := source.add( parser.diagnostics, statement.span, diff --git a/compiler/token/token.odin b/compiler/token/token.odin index d66395b..5e11d79 100644 --- a/compiler/token/token.odin +++ b/compiler/token/token.odin @@ -67,6 +67,8 @@ Kind :: enum u8 { Keyword_If, Keyword_While, Keyword_For, + Keyword_Break, + Keyword_Continue, Keyword_Else, Keyword_True, Keyword_False, diff --git a/compiler_tests.odin b/compiler_tests.odin index 87b2cbf..6fe5465 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2001,6 +2001,66 @@ control_flow_compiles_and_runs :: proc(t: ^testing.T) { testing.expect(t, strings.contains(string(stdout), "or-taken")) } +@(test) +break_and_continue_compile_and_run :: proc(t: ^testing.T) { + output := "/tmp/brolang-test-break-continue" + defer _ = os.remove(output) + status := compiler_core.compile_package("examples/programs/break_continue", output) + testing.expect_value(t, status, 0) + state := run_executable(output) + // `while` break, range-for `continue`, nested innermost-targeting break, a + // `continue` on the final element of an inclusive `u8` range (no overflow + // trap), and an exitable `while true` together produce 42. + testing.expect_value(t, state.exit_code, 42) +} + +@(test) +break_and_continue_misuse_is_diagnosed :: proc(t: ^testing.T) { + // `break`/`continue` outside any loop are rejected, and a non-void function + // that exits a `while true` via `break` without returning is flagged as + // missing a return (the `all_paths_return` refinement). + text := `main :: func() i32 { + bad_break() + bad_continue() + return missing_return() +} +bad_break :: func() void { + break +} +bad_continue :: func() void { + continue +} +missing_return :: func() i32 { + while true { + break + } +} +` + source_file := source.Source{path="test.bro", text=text} + diagnostics := source.init_diagnostics(&source_file) + defer source.destroy_diagnostics(&diagnostics) + symbols := symbol.init_table() + defer symbol.destroy_table(&symbols) + stream := lexer.lex(&source_file, &diagnostics, &symbols) + defer delete(stream.items) + ast_module := parser.parse(&stream, &source_file, &diagnostics) + defer ast.destroy_module(&ast_module) + hir_module := checker.check(&ast_module, &diagnostics, &symbols) + defer hir.destroy_module(&hir_module) + + break_outside := false + continue_outside := false + missing := false + for diagnostic in diagnostics.items { + break_outside = break_outside || strings.contains(diagnostic.message, "'break' outside of a loop") + continue_outside = continue_outside || strings.contains(diagnostic.message, "'continue' outside of a loop") + missing = missing || strings.contains(diagnostic.message, "'missing_return' does not return a value") + } + testing.expect(t, break_outside) + testing.expect(t, continue_outside) + testing.expect(t, missing) +} + @(test) foreign_function_links_from_c_source :: proc(t: ^testing.T) { output := "/tmp/brolang-test-foreign-source" diff --git a/examples/programs/break_continue/main.bro b/examples/programs/break_continue/main.bro new file mode 100644 index 0000000..e0192dd --- /dev/null +++ b/examples/programs/break_continue/main.bro @@ -0,0 +1,58 @@ +# Milestone 18: `break` and `continue`. +# +# `break` exits the innermost loop; `continue` skips to that loop's next +# iteration (running the update / index increment first). Both target the +# innermost enclosing loop. Each section returns a distinct code on failure so +# a regression points at the broken behaviour; success falls through to 42. + +main :: func() i32 { + # 1. `break` out of a `while` once i reaches 5. + i i32 = 0 + a i32 = 0 + while i < 100 : i += 1 { + if (i == 5) break + a += 1 + } + if (a != 5) return 101 + + # 2. `continue` past n == 3 while summing 0..9 (45 - 3 = 42). + b i32 = 0 + for 0..10 |n| { + if (n == 3) continue + b = b + n + } + if (b != 42) return 102 + + # 3. Nested loops: the inner `break` exits only the inner loop, so the outer + # loop still runs all three iterations (each contributing one y == 0 pass). + c i32 = 0 + for 0..3 |x| { + for 0..3 |y| { + if (y == 1) break + c += 1 + } + _ = x + } + if (c != 3) return 103 + + # 4. `continue` on the final element of an inclusive range bounded by the + # element type's maximum must exit cleanly, not overflow the increment. + hi u8 :: 255 + d i32 = 0 + for 0..=hi |v| { + if (v == 255) continue + d += 1 + } + if (d != 255) return 104 + + # 5. `while true` is exitable via `break` (so it is not an infinite loop and + # the code after it is reachable). + e i32 = 0 + while true { + e += 1 + if (e == 7) break + } + if (e != 7) return 105 + + return 42 +}