From 663f4dc6584899d75e0ab1b904fd1b4d0d95f67c Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 22 Jun 2026 20:37:37 +0200 Subject: [PATCH] condtional multi-unwrap and guard clauses --- LANGUAGE.md | 2 +- TODO.md | 8 +- compiler/ast/ast.odin | 11 +- compiler/checker/checker.odin | 204 +++++++++++++++--- compiler/hir/hir.odin | 14 +- compiler/lower/lower.odin | 142 ++++++++---- compiler/parser/parser.odin | 40 +++- compiler_tests.odin | 183 +++++++++++++++- examples/programs/conditional_unwrap/main.bro | 59 ++++- 9 files changed, 567 insertions(+), 96 deletions(-) diff --git a/LANGUAGE.md b/LANGUAGE.md index af04f88..3ece332 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -25,7 +25,7 @@ - information-preserving and information-forgetting pointer-to-array decay and sentinel slice/pointer weakening; array values never implicitly decay - narrow immutable zero-terminated byte pointer conversion to `*c_char` and `[*;0]c_char`, without general `u8`/`c_char` interchange - optionals with trapping postfix `?`, `orelse`, and nullable pointer representation -- conditional optional unwrapping with immutable then-block bindings: `if value |binding| { ... }` +- conditional optional unwrapping with immutable guard/then-block bindings, guarded captures, and left-to-right short-circuiting multi-unwrap: `if first and second |a, b : guard| { ... }` - source-order native structs, defined or opaque `c_struct`, and keyed record literals - complete plain imported C structs and unions as runtime values; incomplete or unsupported-layout records remain pointer-only - C function pointer types as pointer-sized runtime values, including manual `*c_func(...) T` spelling and nullable imported callback typedefs diff --git a/TODO.md b/TODO.md index 85bbf2e..e3f1fbe 100644 --- a/TODO.md +++ b/TODO.md @@ -80,7 +80,7 @@ - function-like macros and non-literal macro expressions remain unsupported - static inline functions (implemented) -5. control flow +5. control flow (implemented) - boolean expressions (implemented) - `bool` type with `true` / `false` literals - comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=` (numeric operands widen; `bool` supports only `==` / `!=`) @@ -93,8 +93,8 @@ - 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: `if val |v : v >= 10| { ... } else { ... }` - unwrap `val` into `v` if it is not `none` - - multi-unwrap (see section below) + - 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 @@ -179,7 +179,7 @@ Ranges represent a sequence of values, commonly used in for loops, and is itself (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. diff --git a/compiler/ast/ast.odin b/compiler/ast/ast.odin index e49dca0..0e2a260 100644 --- a/compiler/ast/ast.odin +++ b/compiler/ast/ast.odin @@ -136,14 +136,18 @@ Stmt :: struct { pointer_capture: bool, target: Expr_Id, expr: Expr_Id, - // `If` statements use `expr` as the condition, `body` as the then-block, and - // `else_body` as the else-block. An `else if` chain is represented as an - // `else_body` holding a single nested `If` statement. + // `If` statements use `expr` as the condition, `captures` as optional + // unwrap binding names, `guard` as the optional post-unwrap boolean + // condition, `body` as the then-block, and `else_body` as the else-block. + // An `else if` chain is represented as an `else_body` holding a single + // nested `If` statement. // `While` statements use `expr` as the condition, `body` as the loop body, // and `update` as the optional post-iteration statement. // `For` statements use `expr` as the iterable, `name` as the item capture, // `index_name` as the optional index capture, and `pointer_capture` to // distinguish `|@item|` from copy capture. + captures: []symbol.Id, + guard: Expr_Id, body: []Stmt_Id, else_body: []Stmt_Id, update: Stmt_Id, @@ -263,6 +267,7 @@ destroy_module :: proc(module: ^Module) { delete(expr.args, module.allocator) } for statement in module.statements { + delete(statement.captures, module.allocator) delete(statement.body, module.allocator) delete(statement.else_body, module.allocator) } diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index ae0ccb9..0dabe99 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -629,6 +629,9 @@ mark_block_imports_used :: proc(checker: ^Checker, statements: []ast.Stmt_Id, fi } case .If: mark_expr_imports_used(checker, statement.expr, file) + if statement.guard != ast.INVALID_EXPR { + mark_expr_imports_used(checker, statement.guard, file) + } mark_block_imports_used(checker, statement.body, file) mark_block_imports_used(checker, statement.else_body, file) case .While: @@ -1403,6 +1406,24 @@ infer_expr :: proc( return last } +flatten_conditional_unwrap_operands :: proc( + module: ^ast.Module, + expr_id: ast.Expr_Id, + operands: ^[dynamic]ast.Expr_Id, +) { + if expr_id == ast.INVALID_EXPR || int(expr_id) >= len(module.exprs) { + append(operands, expr_id) + return + } + expr := module.exprs[expr_id] + if expr.kind == .And { + flatten_conditional_unwrap_operands(module, expr.left, operands) + flatten_conditional_unwrap_operands(module, expr.right, operands) + return + } + append(operands, expr_id) +} + infer_statements :: proc( checker: ^Checker, statements: []ast.Stmt_Id, @@ -1438,16 +1459,36 @@ infer_statements :: proc( } } case .If: - value_type := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded) - if statement.name != symbol.INVALID { - // Conditional unwrap: `v` is in scope (with the unwrapped type) only inside the then-block. - child := types.child_type(value_type, &checker.module.types) if types.is_optional(value_type, &checker.module.types) else types.INVALID - binding_start := len(locals^) - append(locals, Infer_Local{name = statement.name, type = child}) + if len(statement.captures) > 0 { + operands: [dynamic]ast.Expr_Id + operands.allocator = checker.allocator + flatten_conditional_unwrap_operands(checker.ast_module, statement.expr, &operands) + operand_types := make([]types.Type, len(operands), checker.allocator) + for operand, index in operands { + operand_types[index] = infer_expr(checker, operand, locals^[:], pkg, file, demanded) + } + capture_start := len(locals^) + for capture, index in statement.captures { + if capture == checker.sink_symbol { + continue + } + capture_type := types.INVALID + if index < len(operand_types) && + types.is_optional(operand_types[index], &checker.module.types) { + capture_type = types.child_type(operand_types[index], &checker.module.types) + } + append(locals, Infer_Local{name=capture, type=capture_type}) + } + if statement.guard != ast.INVALID_EXPR { + _ = infer_expr(checker, statement.guard, locals^[:], pkg, file, demanded) + } infer_statements(checker, statement.body, locals, pkg, file, demanded, result) - resize(locals, binding_start) + resize(locals, capture_start) infer_statements(checker, statement.else_body, locals, pkg, file, demanded, result) + delete(operand_types, checker.allocator) + delete(operands) } else { + _ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded) infer_statements(checker, statement.body, locals, pkg, file, demanded, result) infer_statements(checker, statement.else_body, locals, pkg, file, demanded, result) } @@ -3142,37 +3183,135 @@ build_block :: proc( }) } case .If: - if statement.name != symbol.INVALID { - // Conditional unwrap `if opt |v| { ... }`: `expr` is the optional, `v` - // binds the unwrapped value (immutable) for the duration of the then-block. - value := build_expr(checker, statement.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file) - value_type := checker.module.exprs[value].type - child := types.INVALID - if checker.module.exprs[value].kind != .Invalid && !types.is_optional(value_type, &checker.module.types) { - id := source.add(checker.diagnostics, statement.span, "'if' unwrap requires an optional value") - value = invalid_hir_expr(checker, statement.span, id) - ctx.problematic^ = true - } else if checker.module.exprs[value].kind != .Invalid { - child = types.child_type(value_type, &checker.module.types) + if len(statement.captures) > 0 { + ast_operands: [dynamic]ast.Expr_Id + ast_operands.allocator = checker.allocator + flatten_conditional_unwrap_operands(checker.ast_module, statement.expr, &ast_operands) + + valid_unwrap := true + diagnostic := source.INVALID_DIAGNOSTIC + if len(ast_operands) != len(statement.captures) { + diagnostic = source.addf( + checker.diagnostics, + statement.span, + "'if' unwrap has %d operands but %d captures", + len(ast_operands), + len(statement.captures), + ) + valid_unwrap = false } - binding := hir.local_id(len(ctx.hir_locals^)) - append(ctx.hir_locals, hir.Local{name = statement.name, type = child, mutable = false}) - locals_before := len(ctx.locals^) - append(ctx.locals, Build_Local{name = statement.name, type = child, mutable = false, id = binding}) - then_body := build_block(ctx, statement.body) - resize(ctx.locals, locals_before) + + 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 + value := build_expr( + checker, operand, ctx.locals^[:], ctx.global_reads, ctx.calls, + types.INVALID, ctx.pkg, ctx.file, + ) + values[index] = value + value_type := checker.module.exprs[value].type + if checker.module.exprs[value].kind == .Invalid { + valid_unwrap = false + if diagnostic == source.INVALID_DIAGNOSTIC { + diagnostic = checker.module.exprs[value].diagnostic + } + } else if !types.is_optional(value_type, &checker.module.types) { + diagnostic = source.addf( + checker.diagnostics, + checker.ast_module.exprs[operand].span, + "'if' unwrap requires an optional value (operand %d)", + index + 1, + ) + valid_unwrap = false + } else { + child_types[index] = types.child_type(value_type, &checker.module.types) + } + } + + capture_start := len(ctx.locals^) + unwraps: [dynamic]hir.Conditional_Unwrap + unwraps.allocator = checker.allocator + for capture, index in statement.captures { + child := child_types[index] if index < len(child_types) else types.INVALID + local := hir.INVALID_LOCAL + if capture != checker.sink_symbol { + if _, duplicate := find_build_local(ctx.locals^[capture_start:], capture); duplicate { + diagnostic = source.add( + checker.diagnostics, + statement.span, + "'if' unwrap captures must have distinct names", + ) + valid_unwrap = 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(&unwraps, hir.Conditional_Unwrap{expr=values[index], local=local}) + } + } + + guard := hir.INVALID_EXPR + if statement.guard != ast.INVALID_EXPR { + guard = build_expr( + checker, statement.guard, ctx.locals^[:], ctx.global_reads, ctx.calls, + types.BOOL, ctx.pkg, ctx.file, + ) + if checker.module.exprs[guard].kind == .Invalid { + valid_unwrap = false + if diagnostic == source.INVALID_DIAGNOSTIC { + diagnostic = checker.module.exprs[guard].diagnostic + } + } else if !types.is_bool(checker.module.exprs[guard].type) { + diagnostic = source.add( + checker.diagnostics, + checker.ast_module.exprs[statement.guard].span, + "'if' unwrap guard must be a bool", + ) + valid_unwrap = false + } + } + + then_body := build_block(ctx, statement.body, capture_start) + resize(ctx.locals, capture_start) else_body: []hir.Stmt_Id = nil if statement.else_body != nil { else_body = build_block(ctx, statement.else_body) } append(&body, hir.stmt_id(len(checker.module.statements))) - append(&checker.module.statements, hir.Stmt{ - kind = .If, span = statement.span, expr = value, - then_body = then_body, else_body = else_body, - local = binding, target = hir.INVALID_EXPR, - diagnostic = source.INVALID_DIAGNOSTIC, - }) - ctx.problematic^ = ctx.problematic^ || checker.module.exprs[value].kind == .Invalid + if valid_unwrap { + append(&checker.module.statements, hir.Stmt{ + kind=.If, + span=statement.span, + expr=hir.INVALID_EXPR, + unwraps=unwraps[:], + guard=guard, + then_body=then_body, + else_body=else_body, + local=hir.INVALID_LOCAL, + target=hir.INVALID_EXPR, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } else { + delete(unwraps) + delete(then_body, checker.allocator) + delete(else_body, checker.allocator) + append(&checker.module.statements, hir.Stmt{ + kind=.Trap, + span=statement.span, + expr=hir.INVALID_EXPR, + guard=hir.INVALID_EXPR, + local=hir.INVALID_LOCAL, + target=hir.INVALID_EXPR, + diagnostic=diagnostic, + }) + ctx.problematic^ = true + } + delete(values, checker.allocator) + delete(child_types, checker.allocator) + delete(ast_operands) continue } condition := build_expr(checker, statement.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.BOOL, ctx.pkg, ctx.file) @@ -3189,6 +3328,7 @@ build_block :: proc( append(&body, hir.stmt_id(len(checker.module.statements))) append(&checker.module.statements, hir.Stmt{ kind = .If, span = statement.span, expr = condition, + guard = hir.INVALID_EXPR, then_body = then_body, else_body = else_body, local = hir.INVALID_LOCAL, target = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, diff --git a/compiler/hir/hir.odin b/compiler/hir/hir.odin index 188a8a0..62ff37f 100644 --- a/compiler/hir/hir.odin +++ b/compiler/hir/hir.odin @@ -134,6 +134,11 @@ Local :: struct { parameter: bool, } +Conditional_Unwrap :: struct { + expr: Expr_Id, + local: Local_Id, +} + Stmt_Kind :: enum u8 { Declaration, Assignment, @@ -155,13 +160,17 @@ Stmt :: struct { expr: Expr_Id, iterator_type: types.Type, pointer_capture: bool, - // `If` statements use `expr` as the condition and `then_body`/`else_body` as - // the branch statement lists. + // Boolean `If` statements use `expr` as the condition. Conditional unwraps + // use `unwraps` for the ordered optional expressions and capture locals, and + // `guard` for the optional boolean checked after every unwrap succeeds. + // Both forms use `then_body`/`else_body` as the branch statement lists. // `While` statements use `expr` as the condition, `then_body` as the loop // body, and `update` as the optional post-iteration statement. // `For` statements use `expr` as the iterable, `local` as the item capture, // `index_local` as the optional sequence index, and `iterator_type` as the // normalized many-item pointer type for sequence iteration. + unwraps: []Conditional_Unwrap, + guard: Expr_Id, then_body: []Stmt_Id, else_body: []Stmt_Id, update: Stmt_Id, @@ -232,6 +241,7 @@ destroy_module :: proc(module: ^Module) { delete(expr.args, module.allocator) } for statement in module.statements { + delete(statement.unwraps, module.allocator) delete(statement.then_body, module.allocator) delete(statement.else_body, module.allocator) } diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index bd9b93b..d000bd5 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -669,57 +669,117 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) { diagnostic=statement.diagnostic, }) case .If: - // A conditional unwrap (`if opt |v| { ... }`) carries the binding local id in - // `statement.local`; `expr` is then the optional, not a bool condition. Test it - // for presence, and inside the then-block bind the unwrapped value to the local. - is_unwrap := statement.local != hir.INVALID_LOCAL - opt := ir.INVALID_INSTRUCTION - cond: ir.Instruction_Id - if is_unwrap { - opt = lower_expr(state, statement.expr) - cond = append_instruction(state, ir.Instruction{ - op=.Optional_Is_Some, span=statement.span, type=types.BOOL, - target=ir.INVALID_REF, a=opt, b=ir.INVALID_INSTRUCTION, - diagnostic=source.INVALID_DIAGNOSTIC, - }) - } else { - cond = lower_expr(state, statement.expr) - } has_else := statement.else_body != nil then_lbl := fresh_label(state) else_lbl := fresh_label(state) if has_else else then_lbl merge_lbl := fresh_label(state) false_target := else_lbl if has_else else merge_lbl - append_instruction(state, ir.Instruction{ - op=.Cond_Br, span=statement.span, type=types.VOID, - a=cond, integer=then_lbl, target=ir.Ref(u32(false_target)), - b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, - }) + + if len(statement.unwraps) > 0 { + // Evaluate each optional exactly once, entering the next operand only + // after the previous one is present. Capture storage is initialized in + // these success blocks so the optional guard can use every binding. + for unwrap in statement.unwraps { + optional := lower_expr(state, unwrap.expr) + present := append_instruction(state, ir.Instruction{ + op=.Optional_Is_Some, + span=statement.span, + type=types.BOOL, + target=ir.INVALID_REF, + a=optional, + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + success_lbl := fresh_label(state) + append_instruction(state, ir.Instruction{ + op=.Cond_Br, + span=statement.span, + type=types.VOID, + a=present, + integer=success_lbl, + target=ir.Ref(u32(false_target)), + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + append_instruction(state, ir.Instruction{ + op=.Label, + span=statement.span, + type=types.VOID, + integer=success_lbl, + target=ir.INVALID_REF, + a=ir.INVALID_INSTRUCTION, + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + if unwrap.local != hir.INVALID_LOCAL && int(unwrap.local) < len(state.func_locals) { + local := state.func_locals[unwrap.local] + slot := append_instruction(state, ir.Instruction{ + op=.Alloca, + span=statement.span, + type=local.type, + target=ir.local_ref(ir.Local_Id(unwrap.local)), + a=ir.INVALID_INSTRUCTION, + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + state.local_slots[unwrap.local] = slot + inner := append_instruction(state, ir.Instruction{ + op=.Optional_Value, + span=statement.span, + type=local.type, + target=ir.INVALID_REF, + a=optional, + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + append_instruction(state, ir.Instruction{ + op=.Store, + span=statement.span, + type=local.type, + target=ir.INVALID_REF, + a=slot, + b=inner, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } + } + if statement.guard != hir.INVALID_EXPR { + guard := lower_expr(state, statement.guard) + append_instruction(state, ir.Instruction{ + op=.Cond_Br, + span=statement.span, + type=types.VOID, + a=guard, + integer=then_lbl, + target=ir.Ref(u32(false_target)), + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } else { + append_instruction(state, ir.Instruction{ + op=.Br, + span=statement.span, + type=types.VOID, + integer=then_lbl, + target=ir.INVALID_REF, + a=ir.INVALID_INSTRUCTION, + b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } + } else { + cond := lower_expr(state, statement.expr) + append_instruction(state, ir.Instruction{ + op=.Cond_Br, span=statement.span, type=types.VOID, + a=cond, integer=then_lbl, target=ir.Ref(u32(false_target)), + b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, + }) + } append_instruction(state, ir.Instruction{ op=.Label, span=statement.span, type=types.VOID, integer=then_lbl, target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, }) - if is_unwrap && int(statement.local) < len(state.func_locals) { - local := state.func_locals[statement.local] - slot := append_instruction(state, ir.Instruction{ - op=.Alloca, span=statement.span, type=local.type, - target=ir.local_ref(ir.Local_Id(statement.local)), - a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, - diagnostic=source.INVALID_DIAGNOSTIC, - }) - state.local_slots[statement.local] = slot - inner := append_instruction(state, ir.Instruction{ - op=.Optional_Value, span=statement.span, type=local.type, - target=ir.INVALID_REF, a=opt, b=ir.INVALID_INSTRUCTION, - diagnostic=source.INVALID_DIAGNOSTIC, - }) - append_instruction(state, ir.Instruction{ - op=.Store, span=statement.span, type=local.type, - target=ir.INVALID_REF, a=slot, b=inner, - diagnostic=source.INVALID_DIAGNOSTIC, - }) - } lower_statements(state, statement.then_body) append_instruction(state, ir.Instruction{ op=.Br, span=statement.span, type=types.VOID, integer=merge_lbl, diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index 27575ad..a40b24d 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -1129,16 +1129,39 @@ parse_if :: proc(parser: ^Parser) -> ast.Stmt_Id { parser.no_struct_literal = true condition := parse_expression(parser) parser.no_struct_literal = saved - binding := symbol.INVALID + captures: [dynamic]symbol.Id + captures.allocator = parser.module.allocator + guard := ast.INVALID_EXPR if _, ok := allow(parser, .Pipe); ok { - name_tok, name_ok := allow(parser, .Identifier) - if name_ok { - binding = name_tok.symbol - } else { - source.add(parser.diagnostics, current(parser).span, "expected a binding name after '|'") + for { + name_tok := current(parser) + if name_tok.kind == .Identifier || name_tok.kind == .Underscore { + advance(parser) + append(&captures, name_tok.symbol) + } else { + source.add(parser.diagnostics, current(parser).span, "expected an unwrap capture name") + break + } + if _, comma_ok := allow(parser, .Comma); !comma_ok { + break + } + if current(parser).kind == .Colon || current(parser).kind == .Pipe { + source.add(parser.diagnostics, current(parser).span, "expected an unwrap capture after ','") + break + } + } + if _, guard_ok := allow(parser, .Colon); guard_ok { + if current(parser).kind == .Pipe { + source.add(parser.diagnostics, current(parser).span, "expected a guard expression after ':'") + } else { + saved = parser.no_struct_literal + parser.no_struct_literal = true + guard = parse_expression(parser) + parser.no_struct_literal = saved + } } if _, close_ok := allow(parser, .Pipe); !close_ok { - source.add(parser.diagnostics, current(parser).span, "expected '|' to close the unwrap binding") + source.add(parser.diagnostics, current(parser).span, "expected '|' to close unwrap captures") } } skip_newlines(parser) @@ -1164,8 +1187,9 @@ parse_if :: proc(parser: ^Parser) -> ast.Stmt_Id { append(&parser.module.statements, ast.Stmt{ kind=.If, span=span_from(start.span, previous(parser).span), - name=binding, expr=condition, + captures=captures[:], + guard=guard, body=then_body, else_body=else_body, diagnostic=source.INVALID_DIAGNOSTIC, diff --git a/compiler_tests.odin b/compiler_tests.odin index e8500d4..79bc58e 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -3823,11 +3823,121 @@ conditional_unwrap_compiles_and_runs :: proc(t: ^testing.T) { status := compiler_core.compile_package("examples/programs/conditional_unwrap", output) testing.expect_value(t, status, 0) state := run_executable(output) - // present scalar binds and unwraps (40), none takes the else (+2), a present - // optional pointer binds and derefs (+0), a none optional pointer is skipped. + // Single unwrap, guarded two/three-value unwraps, optional pointers, false + // guards, and failed short-circuit chains preserve the expected total. testing.expect_value(t, state.exit_code, 42) } +@(test) +conditional_unwrap_parser_captures_guard_and_parenthesized_chain :: proc(t: ^testing.T) { + text := `main :: func() void { + first ?i32 = 1 + second ?i32 = 2 + if (first and second) |a, b : a == 1 and b == 2| { + _ = a + _ = b + } +} +` + 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) + module := parser.parse(&stream, &source_file, &diagnostics) + defer ast.destroy_module(&module) + + testing.expect_value(t, len(diagnostics.items), 0) + statement := module.statements[module.functions[0].body[2]] + testing.expect_value(t, statement.kind, ast.Stmt_Kind.If) + testing.expect_value(t, len(statement.captures), 2) + testing.expect_value(t, module.exprs[statement.expr].kind, ast.Expr_Kind.And) + testing.expect(t, module.exprs[statement.expr].parenthesized) + testing.expect(t, statement.guard != ast.INVALID_EXPR) + testing.expect_value(t, module.exprs[statement.guard].kind, ast.Expr_Kind.And) +} + +@(test) +conditional_unwrap_allows_sink_captures :: proc(t: ^testing.T) { + text := `main :: func() void { + first ?i32 = 1 + second ?i32 = 2 + if first and second |_, value : value == 2| { + _ = value + } + if first |_| {} +} +` + 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) + + testing.expect_value(t, len(diagnostics.items), 0) + main := hir_module.functions[0] + first_if := hir_module.statements[main.body[2]] + second_if := hir_module.statements[main.body[3]] + testing.expect_value(t, first_if.unwraps[0].local, hir.INVALID_LOCAL) + testing.expect(t, first_if.unwraps[1].local != hir.INVALID_LOCAL) + testing.expect_value(t, second_if.unwraps[0].local, hir.INVALID_LOCAL) +} + +@(test) +parser_diagnoses_malformed_conditional_unwrap_captures_and_guards :: proc(t: ^testing.T) { + cases := [4]struct { + text: string, + needle: string, + }{ + {`main :: func() void { + value ?i32 = 1 + if value || {} +} +`, "expected an unwrap capture name"}, + {`main :: func() void { + value ?i32 = 1 + if value |capture,| {} +} +`, "expected an unwrap capture after ','"}, + {`main :: func() void { + value ?i32 = 1 + if value |capture :| {} +} +`, "expected a guard expression after ':'"}, + {`main :: func() void { + value ?i32 = 1 + if value |capture {} +} +`, "expected '|' to close unwrap captures"}, + } + for test_case in cases { + source_file := source.Source{path="test.bro", text=test_case.text} + diagnostics := source.init_diagnostics(&source_file) + symbols := symbol.init_table() + stream := lexer.lex(&source_file, &diagnostics, &symbols) + module := parser.parse(&stream, &source_file, &diagnostics) + + found := false + for diagnostic in diagnostics.items { + found = found || strings.contains(diagnostic.message, test_case.needle) + } + testing.expect(t, found) + + ast.destroy_module(&module) + delete(stream.items) + symbol.destroy_table(&symbols) + source.destroy_diagnostics(&diagnostics) + } +} + @(test) if_unwrap_on_non_optional_is_diagnosed :: proc(t: ^testing.T) { text := `main :: func() i32 { @@ -3857,6 +3967,75 @@ if_unwrap_on_non_optional_is_diagnosed :: proc(t: ^testing.T) { testing.expect(t, found) } +@(test) +conditional_unwrap_diagnostics_cover_counts_guards_and_capture_scope :: proc(t: ^testing.T) { + text := `main :: func() void { + first ?i32 = 1 + second ?i32 = 2 + plain i32 = 3 + + if first and second |one| {} + if first |one, two| {} + if first and second |same, same| {} + if plain |value| {} + if first |value : value| {} + if first and earlier |earlier, later| {} + + if first |value| { + value = 2 + } + if first |value| { + value i32 = 2 + _ = value + } + if first |value| { + _ = value + } else { + _ = value + } + _ = value +} +` + 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) + + count_mismatches := 0 + duplicate := false + non_optional := false + guard := false + outer_operand_scope := false + immutable := false + redeclaration := false + capture_scope := 0 + for diagnostic in diagnostics.items { + count_mismatches += 1 if strings.contains(diagnostic.message, "unwrap has") else 0 + duplicate = duplicate || strings.contains(diagnostic.message, "unwrap captures must have distinct names") + non_optional = non_optional || strings.contains(diagnostic.message, "unwrap requires an optional value") + guard = guard || strings.contains(diagnostic.message, "unwrap guard must be a bool") + outer_operand_scope = outer_operand_scope || strings.contains(diagnostic.message, "unresolved global 'earlier'") + immutable = immutable || strings.contains(diagnostic.message, "cannot assign immutable local 'value'") + redeclaration = redeclaration || strings.contains(diagnostic.message, "duplicate local 'value'") + capture_scope += 1 if strings.contains(diagnostic.message, "unresolved global 'value'") else 0 + } + testing.expect_value(t, count_mismatches, 2) + testing.expect(t, duplicate) + testing.expect(t, non_optional) + testing.expect(t, guard) + testing.expect(t, outer_operand_scope) + testing.expect(t, immutable) + testing.expect(t, redeclaration) + testing.expect_value(t, capture_scope, 2) +} + @(test) if_unwrap_binding_is_scoped_to_then_block :: proc(t: ^testing.T) { // The binding `v` is usable in the then-block but not in the else-block. diff --git a/examples/programs/conditional_unwrap/main.bro b/examples/programs/conditional_unwrap/main.bro index 17bd449..c3eee97 100644 --- a/examples/programs/conditional_unwrap/main.bro +++ b/examples/programs/conditional_unwrap/main.bro @@ -1,6 +1,9 @@ -# Milestone 5: conditional unwrapping `if opt |v| { ... }`. -# Tests a present optional binds and unwraps, a none takes the else branch, and -# that optional pointers (?*T) unwrap the same way. +# Milestone 5: conditional optional unwrapping, guards, and multi-unwrap. + +observe :: func(counter @mut i32, value ?i32) ?i32 { + counter^ = counter^ + 1 + return value +} main :: func() i32 { total i32 = 0 @@ -34,5 +37,55 @@ main :: func() i32 { total = total + 1000 } + # guarded multi-unwrap exposes every capture to the guard and then-block + age ?i32 = 2 + if a and age |value, years : value + years == 42| { + total = total + } else { + total = total + 100 + } + + # parenthesized chains and three-value unwraps are equivalent + bonus ?i32 = 0 + if (a and age and bonus) |value, years, extra : value + years + extra == 42| { + total = total + } else { + total = total + 100 + } + + # false guards use the else branch, or simply fall through without one + if a |value : value == 0| { + total = total + 100 + } else { + total = total + } + if a |value : value == 0| { + total = total + 100 + } + + # a failed unwrap prevents later expressions from being evaluated + calls i32 = 0 + if b and observe(&calls, age) |missing, observed| { + total = total + missing + observed + } + if calls != 0 { + total = total + 100 + } + + # short-circuiting also applies after an earlier successful unwrap + if a and b and observe(&calls, age) |value, missing, observed| { + total = total + value + missing + observed + } + if calls != 0 { + total = total + 100 + } + + # optional pointers participate in multi-unwrap and guards + if a and p |value, q : value == 40 and q^ == 0| { + total = total + } else { + total = total + 100 + } + return total # expect exit code 42 }