From 7ca7e33033141ad2e83f43dfed1f10f876c4b47b Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 30 Jun 2026 20:32:13 +0200 Subject: [PATCH] fallible ergonomics --- TODO.md | 22 ++++----- compiler/checker/checker.odin | 57 ++++++++++++++++++----- compiler/hir/hir.odin | 4 ++ compiler/ir/ir.odin | 1 + compiler/llvm/llvm.odin | 54 +++++++++++++++++++++- compiler/lower/lower.odin | 50 ++++++++++++++++++++- compiler_tests.odin | 75 +++++++++++++++++++++++++++++++ examples/programs/errors/main.bro | 51 +++++++++++++++++++-- 8 files changed, 289 insertions(+), 25 deletions(-) diff --git a/TODO.md b/TODO.md index cd91802..7826bef 100644 --- a/TODO.md +++ b/TODO.md @@ -576,10 +576,12 @@ merge/conflict/widening/rejections and the `.Yield` inference regression - deferred follow-ups are split below; 23.5 keeps the next user-visible slice small -23.5. fallible ergonomics: catch blocks + composable try widening - - implement `expr catch |e| { ... }` with `e : E` - - allow `try` to widen `T ! E1` into an enclosing `T ! (E1 | E2)` channel - - add focused tests/examples for both +23.5. fallible ergonomics: catch blocks + composable try widening (implemented) + - `expr catch |e| { ... }` binds `e : E` in the handler and uses the existing value-block + `yield` rules to produce the fallback success value + - `try` still requires the same success type `T`, but now propagates either the exact error + channel or a sum-widenable `E1` into an enclosing `E1 | E2` + - focused coverage lives in `examples/programs/errors` and the fallible ergonomics compiler test - leave ABI/layout/lint/design polish for later milestones 23.6. contextual payload construction + inline error types @@ -974,7 +976,7 @@ message :: match code { Brolang handles errors as values. There is no hidden control flow — a function that can fail declares this in its signature, and the caller must explicitly handle the possibility of failure. -Milestone 23 v1 implements named error channels, native sum composition, `return`-based error dispatch, exact-channel `try`, and fallback `catch`. It intentionally defers the `error` keyword shorthand, inline error types, `catch |e|` blocks, and `try` widening across different-but-composable error channels. +Milestone 23 v1 implements named error channels, native sum composition, `return`-based error dispatch, exact-channel `try`, and fallback `catch`. Milestone 23.5 adds `catch |e|` blocks and `try` widening across composable error channels. It still defers the `error` keyword shorthand, inline error types, contextual payload construction, and match-on-error shorthand. ### Fallible Functions @@ -1093,11 +1095,11 @@ process func(path []u8) Ast ! ProcessError { } ``` -In v1, `try` propagates only when the callee's error channel exactly matches the enclosing function's error channel. Widening narrower error types into a composed return channel is a planned follow-up. +`try` propagates when the success type matches the enclosing fallible function and the callee's error channel either exactly matches or can widen into the enclosing composed error channel. ### Handling with `catch` -The `catch` keyword handles errors and provides a value to continue with. Milestone 23 v1 implements the fallback-value form. +The `catch` keyword handles errors and provides a value to continue with. It supports both fallback values and block handlers. **Provide a fallback value:** @@ -1105,7 +1107,7 @@ The `catch` keyword handles errors and provides a value to continue with. Milest data :: read_file(path) catch default_data ``` -**Planned block form using** `yield` (deferred in v1): +**Block form using** `yield`: ``` data :: read_file(path) catch |e| { @@ -1156,9 +1158,9 @@ data :: read_file(path) catch |e| match e { | `return e` | Exit function via error channel when `e : E` | | `error e` | Planned shorthand, not v1 | | `error .variant{...}` | Planned shorthand, not v1 | -| `try expr` | Unwrap success or propagate an exact matching error channel | +| `try expr` | Unwrap success or propagate an exact/sum-widenable error channel | | `expr catch fallback` | Provide fallback value on error | -| `expr catch |e| { ... }` | Planned block handler, not v1 | +| `expr catch |e| { ... }` | Bind `e : E` and yield a fallback value from the handler | | `yield value` | Provide value from innermost block | | `yield :label value` | Provide value from labeled block | | `return` / `return value` | Exit the current function; in fallible functions, return value dispatches by type | diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index db0f03e..17c5610 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -168,6 +168,7 @@ Checker :: struct { main_symbol: symbol.Id, sink_symbol: symbol.Id, current_result: types.Type, + current_build_ctx: ^Build_Ctx, target: target.Target, allocator: mem.Allocator, } @@ -3429,9 +3430,20 @@ build_compound_expr :: proc( id := source.add(checker.diagnostics, expr.span, "'try' requires a fallible expression") return invalid_hir_expr(checker, expr.span, id) } - if !types.equal(channel_type, checker.current_result) { - // ponytail: exact channel propagation; add fallible-error widening when cross-error-set try matters. - id := source.add(checker.diagnostics, expr.span, "'try' can only propagate the enclosing function's exact error channel in v1") + enclosing_success := types.fallible_success(checker.current_result, store) + enclosing_error := types.fallible_error(checker.current_result, store) + if !types.is_valid(enclosing_success) { + id := source.add(checker.diagnostics, expr.span, "'try' requires an enclosing fallible function") + return invalid_hir_expr(checker, expr.span, id, success) + } + if !types.equal(success, enclosing_success) { + id := source.add(checker.diagnostics, expr.span, "'try' success type must match the enclosing fallible result") + return invalid_hir_expr(checker, expr.span, id, success) + } + error_type := types.fallible_error(channel_type, store) + if !types.equal(error_type, enclosing_error) && + !types.can_sum_widen(error_type, enclosing_error, store) { + id := source.add(checker.diagnostics, expr.span, "'try' error channel cannot be widened to the enclosing error channel") return invalid_hir_expr(checker, expr.span, id, success) } return add_hir_expr(checker, hir.Expr{ @@ -3451,20 +3463,42 @@ build_compound_expr :: proc( id := source.add(checker.diagnostics, expr.span, "'catch' requires a fallible expression") return invalid_hir_expr(checker, expr.span, id) } - if expr.right == ast.INVALID_EXPR { - // ponytail: catch blocks need Build_Ctx threading through expression build; fallback catch covers v1. - id := source.add(checker.diagnostics, expr.span, "catch block form is not implemented in v1") - return invalid_hir_expr(checker, expr.span, id, success) + body: []hir.Stmt_Id + capture := hir.INVALID_LOCAL + block_handler := false + fallback := hir.INVALID_EXPR + if expr.right != ast.INVALID_EXPR { + fallback = build_nested_expr(checker, expr.right, locals, global_reads, calls, success, pkg, file) + fallback = coerce_expr(checker, fallback, success, checker.module.exprs[fallback].span) + } else { + block_handler = true + ctx := checker.current_build_ctx + if ctx == nil { + id := source.add(checker.diagnostics, expr.span, "catch block form is only valid in a function body") + return invalid_hir_expr(checker, expr.span, id, success) + } + capture_start := len(ctx.locals^) + error_type := types.fallible_error(channel_type, store) + if symbol.is_valid(expr.name) && expr.name != checker.sink_symbol { + capture = hir.local_id(len(ctx.hir_locals^)) + append(ctx.hir_locals, hir.Local{name=expr.name, type=error_type, mutable=false}) + append(ctx.locals, Build_Local{name=expr.name, type=error_type, mutable=false, id=capture}) + } + handler: [dynamic]hir.Stmt_Id + handler.allocator = checker.allocator + fallback, _ = build_value_source(ctx, &handler, expr.body, success, expr.span) + body = handler[:] + resize(ctx.locals, capture_start) } - fallback := build_nested_expr(checker, expr.right, locals, global_reads, calls, success, pkg, file) - fallback = coerce_expr(checker, fallback, success, checker.module.exprs[fallback].span) return add_hir_expr(checker, hir.Expr{ kind=.Catch, span=expr.span, type=success, + integer=1 if block_handler else 0, left=channel, right=fallback, - target=hir.INVALID_REF, + body=body, + target=hir.local_ref(capture) if capture != hir.INVALID_LOCAL else hir.INVALID_REF, diagnostic=source.INVALID_DIAGNOSTIC, }) case .Range: @@ -6727,9 +6761,12 @@ build_function :: proc(checker: ^Checker, id: Spec_Id) { yield_targets = &yield_targets, } previous_result := checker.current_result + previous_ctx := checker.current_build_ctx checker.current_result = spec.result + checker.current_build_ctx = &ctx block := build_block(&ctx, function.body) checker.current_result = previous_result + checker.current_build_ctx = previous_ctx returns := all_paths_return(&checker.module, block) for block_stmt in block { append(&body, block_stmt) diff --git a/compiler/hir/hir.odin b/compiler/hir/hir.odin index 77c9961..ee190a1 100644 --- a/compiler/hir/hir.odin +++ b/compiler/hir/hir.odin @@ -129,6 +129,9 @@ Expr :: struct { type: types.Type, integer: i64, args: []Expr_Id, + // `Catch` block handlers use `body` for the handler statements and `target` + // for the optional captured error local. + body: []Stmt_Id, target: Ref, left: Expr_Id, right: Expr_Id, @@ -269,6 +272,7 @@ init_module :: proc(selected := target.DEFAULT, allocator := context.allocator) destroy_module :: proc(module: ^Module) { for expr in module.exprs { delete(expr.args, module.allocator) + delete(expr.body, module.allocator) } for statement in module.statements { delete(statement.unwraps, module.allocator) diff --git a/compiler/ir/ir.odin b/compiler/ir/ir.odin index 428bf8f..2870e07 100644 --- a/compiler/ir/ir.odin +++ b/compiler/ir/ir.odin @@ -80,6 +80,7 @@ Opcode :: enum u8 { Index_Address, Field_Address, Union_Tag, + Fallible_Error, Load, Store, Fill, diff --git a/compiler/llvm/llvm.odin b/compiler/llvm/llvm.odin index a9bac36..fc989c6 100644 --- a/compiler/llvm/llvm.odin +++ b/compiler/llvm/llvm.odin @@ -254,7 +254,7 @@ valid_value :: proc( switch instructions[value_id].op { case .Param, .Const, .String, .Aggregate, .None, .Optional_Some, .Load_Global, .Function_Address, .Address_Of, .Load, .Union_Tag, .Slice, .Length, .Slice_Ptr, - .Extract, .Select, .Unwrap, + .Fallible_Error, .Extract, .Select, .Unwrap, .Optional_Is_Some, .Optional_Value, .Orelse, .Widen, .Sum_Widen, .C_Coerce, .C_Vararg_Promote, .Retype, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer, .Neg_Checked, .Add_Checked, .Sub_Checked, .Mul_Checked, .Div_Checked, .Pointer_Add, .Not, .Compare, .Call: @@ -1056,6 +1056,37 @@ emit_instruction_stream :: proc( llvm_type(instruction.type, &emitter.module.types), instruction.a, ) + case .Fallible_Error: + channel_type := instructions[instruction.a].type if valid_instruction(instructions, instruction.a) else types.INVALID + error_type := types.fallible_error(channel_type, &emitter.module.types) + if !types.equal(error_type, instruction.type) || + !valid_address(instructions, instruction.a, channel_type, &emitter.module.types) { + emit_recovery_value(emitter, instruction_index, instruction, "invalid fallible error operand") + continue + } + if types.is_enum(error_type, &emitter.module.types) { + fmt.sbprintf(&emitter.builder, " %%v%d = load %s, ptr %%v%d\n", instruction_index, llvm_type(error_type, &emitter.module.types), instruction.a) + continue + } + if types.is_tagged_union(error_type, &emitter.module.types) { + type_name := llvm_type(error_type, &emitter.module.types) + align := types.alignment_of(error_type, &emitter.module.types, emitter.module.target) + fmt.sbprintf(&emitter.builder, " %%fallible_error_slot%d = alloca %s, align %d\n", instruction_index, type_name, align) + fmt.sbprintf(&emitter.builder, " store %s zeroinitializer, ptr %%fallible_error_slot%d\n", type_name, instruction_index) + fmt.sbprintf(&emitter.builder, " %%fallible_error_code%d = load i16, ptr %%v%d\n", instruction_index, instruction.a) + fmt.sbprintf(&emitter.builder, " store i16 %%fallible_error_code%d, ptr %%fallible_error_slot%d\n", instruction_index, instruction_index) + payload_size := types.sum_payload_size(error_type, &emitter.module.types, emitter.module.target) + if payload_size > 0 { + source_offset := types.fallible_payload_offset(channel_type, &emitter.module.types, emitter.module.target) + target_offset := types.union_payload_offset(error_type, &emitter.module.types, emitter.module.target) + fmt.sbprintf(&emitter.builder, " %%fallible_error_source%d = getelementptr i8, ptr %%v%d, i64 %d\n", instruction_index, instruction.a, source_offset) + fmt.sbprintf(&emitter.builder, " %%fallible_error_payload%d = getelementptr i8, ptr %%fallible_error_slot%d, i64 %d\n", instruction_index, instruction_index, target_offset) + fmt.sbprintf(&emitter.builder, " call void @llvm.memcpy.p0.p0.i64(ptr %%fallible_error_payload%d, ptr %%fallible_error_source%d, i64 %d, i1 false)\n", instruction_index, instruction_index, payload_size) + } + fmt.sbprintf(&emitter.builder, " %%v%d = load %s, ptr %%fallible_error_slot%d\n", instruction_index, type_name, instruction_index) + continue + } + emit_recovery_value(emitter, instruction_index, instruction, "unsupported fallible error type") case .Store: if !valid_address(instructions, instruction.a, instruction.type, &emitter.module.types) || !valid_value(instructions, instruction.b, instruction.type, &emitter.module.types) { @@ -1324,6 +1355,27 @@ emit_instruction_stream :: proc( fmt.sbprintf(&emitter.builder, ", %s zeroinitializer\n", type_name) continue } + if types.is_enum(from_type, &emitter.module.types) && types.is_tagged_union(instruction.type, &emitter.module.types) { + to_name := llvm_type(instruction.type, &emitter.module.types) + to_align := types.alignment_of(instruction.type, &emitter.module.types, emitter.module.target) + fmt.sbprintf(&emitter.builder, " %%sum_to_slot%d = alloca %s, align %d\n", instruction_index, to_name, to_align) + fmt.sbprintf(&emitter.builder, " store %s zeroinitializer, ptr %%sum_to_slot%d\n", to_name, instruction_index) + fmt.sbprintf(&emitter.builder, " store i16 ") + write_operand(&emitter.builder, instructions, instruction.a, from_type, &emitter.module.types) + fmt.sbprintf(&emitter.builder, ", ptr %%sum_to_slot%d\n", instruction_index) + fmt.sbprintf(&emitter.builder, " %%v%d = load %s, ptr %%sum_to_slot%d\n", instruction_index, to_name, instruction_index) + continue + } + if types.is_tagged_union(from_type, &emitter.module.types) && types.is_enum(instruction.type, &emitter.module.types) { + from_name := llvm_type(from_type, &emitter.module.types) + from_align := types.alignment_of(from_type, &emitter.module.types, emitter.module.target) + fmt.sbprintf(&emitter.builder, " %%sum_from_slot%d = alloca %s, align %d\n", instruction_index, from_name, from_align) + fmt.sbprintf(&emitter.builder, " store %s ", from_name) + write_operand(&emitter.builder, instructions, instruction.a, from_type, &emitter.module.types) + fmt.sbprintf(&emitter.builder, ", ptr %%sum_from_slot%d\n", instruction_index) + fmt.sbprintf(&emitter.builder, " %%v%d = load %s, ptr %%sum_from_slot%d\n", instruction_index, llvm_type(instruction.type, &emitter.module.types), instruction_index) + continue + } if types.is_tagged_union(from_type, &emitter.module.types) && types.is_tagged_union(instruction.type, &emitter.module.types) { from_name := llvm_type(from_type, &emitter.module.types) to_name := llvm_type(instruction.type, &emitter.module.types) diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index 9157149..60b10ff 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -412,12 +412,60 @@ lower_compound_expr :: proc(state: ^State, expr_id: hir.Expr_Id) -> ir.Instructi diagnostic=source.INVALID_DIAGNOSTIC, }) if expr.kind == .Try { + result := channel + if !types.equal(channel_type, state.func_result) { + error_type := types.fallible_error(channel_type, &state.hir_module.types) + enclosing_error := types.fallible_error(state.func_result, &state.hir_module.types) + error_value := append_instruction(state, ir.Instruction{ + op=.Fallible_Error, span=expr.span, type=error_type, + target=ir.INVALID_REF, a=channel_slot, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + if !types.equal(error_type, enclosing_error) { + error_value = append_instruction(state, ir.Instruction{ + op=.Sum_Widen, span=expr.span, type=enclosing_error, + target=ir.INVALID_REF, a=error_value, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } + args := make([]ir.Instruction_Id, 1, state.allocator) + args[0] = error_value + result = append_instruction(state, ir.Instruction{ + op=.Aggregate, span=expr.span, type=state.func_result, integer=1, + args=args, target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, + b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, + }) + } append_instruction(state, ir.Instruction{ op=.Return, span=expr.span, type=state.func_result, - target=ir.INVALID_REF, a=channel, b=ir.INVALID_INSTRUCTION, + target=ir.INVALID_REF, a=result, b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, }) } else { + if expr.integer != 0 { + capture := hir.as_local(expr.target) + if capture != hir.INVALID_LOCAL && int(capture) < len(state.func_locals) { + error_type := state.func_locals[capture].type + error_value := append_instruction(state, ir.Instruction{ + op=.Fallible_Error, span=expr.span, type=error_type, + target=ir.INVALID_REF, a=channel_slot, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + capture_slot := append_instruction(state, ir.Instruction{ + op=.Alloca, span=expr.span, type=error_type, + target=ir.local_ref(ir.Local_Id(capture)), + a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + state.local_slots[capture] = capture_slot + append_instruction(state, ir.Instruction{ + op=.Store, span=expr.span, type=error_type, + target=ir.INVALID_REF, a=capture_slot, b=error_value, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } + lower_statements(state, expr.body) + } fallback := lower_nested_expr(state, expr.right) append_instruction(state, ir.Instruction{ op=.Store, span=expr.span, type=success, diff --git a/compiler_tests.odin b/compiler_tests.odin index c3ba286..0c28564 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2439,6 +2439,81 @@ main func() i32 { testing.expect(t, len(hir_module.functions) > 1) } +@(test) +fallible_ergonomics_rejects_bad_try_and_catch_blocks :: proc(t: ^testing.T) { + text := `A :: enum { + a +} +B :: enum { + b +} +fa func() i32 ! A { + return .a +} +bad_error func() i32 ! B { + x :: try fa() + return x +} +bad_catch func() i32 { + return fa() catch |e| { + _ = e + } +} +main func() i32 { + _ = bad_error() catch 0 + return bad_catch() +} +` + source_file := source.Source{path="fallible_ergonomics.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) + + found_error := false + found_yield := false + for diagnostic in diagnostics.items { + found_error = found_error || strings.contains(diagnostic.message, "'try' error channel cannot be widened") + found_yield = found_yield || strings.contains(diagnostic.message, "a value block must end with an explicit 'yield'") + } + testing.expect(t, found_error) + testing.expect(t, found_yield) + + success_text := `A :: enum { + a +} +B :: enum { + b +} +Both :: alias A | B +fs func() i64 ! A { + return 1 +} +bad_success func() i32 ! Both { + x :: try fs() + return x +} +main func() i32 { + return bad_success() catch 0 +} +` + directory := "/tmp/brolang-test-try-success-mismatch" + main_path := "/tmp/brolang-test-try-success-mismatch/main.bro" + output := "/tmp/brolang-test-try-success-mismatch-output" + _ = os2.remove_all(directory) + defer _ = os2.remove_all(directory) + defer _ = os.remove(output) + testing.expect(t, os.make_directory(directory) == nil) + testing.expect(t, os.write_entire_file(main_path, transmute([]byte)success_text)) + testing.expect_value(t, compiler_core.compile_package(directory, output), 1) +} + @(test) errors_example_compiles_and_runs :: proc(t: ^testing.T) { output := "/tmp/brolang-test-errors" diff --git a/examples/programs/errors/main.bro b/examples/programs/errors/main.bro index e2366e8..3da2fcf 100644 --- a/examples/programs/errors/main.bro +++ b/examples/programs/errors/main.bro @@ -8,6 +8,8 @@ DetailError :: union(enum) { empty void } +BasicOrDetail :: alias BasicError | DetailError + Left :: enum { left } @@ -30,6 +32,7 @@ Box :: alias BoxA | BoxB maybe func(value i32) i32 ! BasicError { if (value == 0) return .bad + if (value < 0) return .worse return value + 1 } @@ -44,6 +47,41 @@ with_detail func(value i32) i32 ! DetailError { return value } +via_widen func(value i32) i32 ! BasicOrDetail { + unwrapped :: try maybe(value) + return unwrapped + 2 +} + +catch_basic func(value i32) i32 { + return maybe(value) catch |e| { + if (e == .bad) { + yield 21 + } else { + yield 22 + } + } +} + +catch_detail func(value i32) i32 { + return with_detail(value) catch |e| { + match e { + .code |n|: yield n + 30 + .empty: yield 40 + } + } +} + +catch_widen func(value i32) i32 { + return via_widen(value) catch |e| { + match e { + .bad: yield 50 + .worse: yield 51 + .code |n|: yield n + .empty: yield 52 + } + } +} + pick func(value Both) i32 { match value { .left: return 10 @@ -67,8 +105,15 @@ main func() i32 { d :: via_try(0) catch 11 e :: with_detail(0) catch 13 f :: with_detail(2) catch 99 + g :: catch_basic(0) + h :: catch_basic(-1) + i :: catch_detail(0) + j :: catch_detail(1) + k :: via_widen(3) catch 99 + l :: catch_widen(0) + m :: catch_widen(-1) - acc = acc + a + b + c + d + e + f + acc = acc + a + b + c + d + e + f + g + h + i + j + k + l + m acc = acc + pick(.left) r Right = .right acc = acc + pick(r) @@ -77,6 +122,6 @@ main func() i32 { empty BoxB = .b acc = acc + payload(empty) - # 7 + 5 + 5 + 11 + 13 + 2 + 10 + 20 + 8 + 3 = 84 - return acc - 84 + # 7 + 5 + 5 + 11 + 13 + 2 + 21 + 22 + 35 + 40 + 6 + 50 + 51 + 10 + 20 + 8 + 3 = 309 + return acc - 309 }