From d6f9c24314afa527307d34e1cf13a49922c9b729 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Tue, 30 Jun 2026 23:01:18 +0200 Subject: [PATCH] anonymous struct payloads --- TODO.md | 15 ++- compiler/checker/checker.odin | 24 +++-- compiler/parser/parser.odin | 93 ++++++++++++++---- compiler/types/types.odin | 36 +++++++ compiler_tests.odin | 155 ++++++++++++++++++++++++++++++ examples/programs/errors/main.bro | 32 ++++-- 6 files changed, 317 insertions(+), 38 deletions(-) diff --git a/TODO.md b/TODO.md index a92a88a..7f0bcf0 100644 --- a/TODO.md +++ b/TODO.md @@ -595,7 +595,13 @@ - named fallible signatures can use inline unbacked enum and `union(enum)` error types after `!` -23.7. anonymous struct payloads and keyed payload sugar +23.7. anonymous struct payloads and keyed payload sugar (implemented) + - tagged-union variants can use anonymous `struct { ... }` payloads, scoped to variant payload + declarations rather than general anonymous type syntax + - contextual `.variant{field = value, ...}` constructs struct payloads by key, reusing ordinary + struct-literal validation for unknown, duplicate, missing, and mismatched fields + - structurally identical anonymous struct payloads share type identity, so sum composition merges + matching variants and still rejects same-name variants with different payload shapes 24. dynamic heap allocation - see below for direction @@ -978,7 +984,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`. Milestone 23.5 adds `catch |e|` blocks and `try` widening across composable error channels. Milestone 23.6 adds contextual payload construction and inline error types. It still defers the `error` keyword shorthand, anonymous struct payload sugar, and match-on-error shorthand. +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. Milestone 23.6 adds contextual payload construction and inline error types. Milestone 23.7 adds anonymous struct payloads and keyed payload sugar. It still defers the `error` keyword shorthand and match-on-error shorthand. ### Fallible Functions @@ -1081,7 +1087,7 @@ parse_section func(p: @mut Parser) void ! ParseError { # ... parsing logic ... - if p.pos >= p.input.len or p.input[p.pos] != ']' return .unclosed_section{start_line} + if p.pos >= p.input.len or p.input[p.pos] != ']' return .unclosed_section{line = start_line} # ... continue on success ... } @@ -1177,7 +1183,8 @@ data :: read_file(path) catch |e| match e { | `T ! (E1 | E2)` | Planned direct spelling with parentheses; use a named alias in v1 | | `return e` | Exit function via error channel when `e : E` | | `error e` | Planned shorthand, not v1 | -| `.variant{payload}` | Construct a payload-carrying tagged-union variant from context | +| `.variant{payload}` | Construct a payload-carrying tagged-union variant from one payload expression | +| `.variant{field = value, ...}` | Construct a struct-payload tagged-union variant from context | | `error .variant{...}` | Planned shorthand, not v1 | | `try expr` | Unwrap success or propagate an exact/sum-widenable error channel | | `expr catch fallback` | Provide fallback value on error | diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index 59c6c87..5e8bf28 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -1417,6 +1417,9 @@ infer_compound_expr :: proc( for keyed in expr.args { _ = infer_nested_expr(checker, checker.ast_module.exprs[keyed].left, locals, pkg, file, demanded, local_types) } + if !symbol.is_valid(expr.name) { + return types.INVALID + } target_pkg, available := expr_package(checker, expr, pkg, file) value := types.find_named(store, u32(target_pkg), u32(expr.name)) if available else types.INVALID return types.resolve_alias(value, store) @@ -3659,12 +3662,21 @@ build_compound_expr :: proc( target=hir.INVALID_REF, diagnostic=source.INVALID_DIAGNOSTIC, }) case .Struct_Literal: - target_pkg, available := expr_package(checker, expr, pkg, file, true) - struct_type := types.find_named(store, u32(target_pkg), u32(expr.name)) if available else types.INVALID - struct_type = types.resolve_alias(struct_type, store) - if !types.is_record(struct_type, store) || types.is_opaque_struct(struct_type, store) { - id := source.addf(checker.diagnostics, expr.span, "unknown or opaque record type '%s'", symbol_text(checker, expr.name)) - return invalid_hir_expr(checker, expr.span, id) + struct_type := types.INVALID + if symbol.is_valid(expr.name) { + target_pkg, available := expr_package(checker, expr, pkg, file, true) + struct_type = types.find_named(store, u32(target_pkg), u32(expr.name)) if available else types.INVALID + struct_type = types.resolve_alias(struct_type, store) + if !types.is_record(struct_type, store) || types.is_opaque_struct(struct_type, store) { + id := source.addf(checker.diagnostics, expr.span, "unknown or opaque record type '%s'", symbol_text(checker, expr.name)) + return invalid_hir_expr(checker, expr.span, id) + } + } else { + struct_type = types.resolve_alias(expected, store) + if !types.is_struct(struct_type, store) || types.is_opaque_struct(struct_type, store) { + id := source.add(checker.diagnostics, expr.span, "keyed contextual payload requires a struct payload") + return invalid_hir_expr(checker, expr.span, id, expected) + } } fields := types.fields_for(store, struct_type) union_record := types.is_union(struct_type, store) diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index c1ed4c2..6cdc3c8 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -494,13 +494,12 @@ parse_array_literal :: proc(parser: ^Parser, nesting: int) -> ast.Expr_Id { }) } -parse_struct_literal :: proc( +parse_keyed_initializers :: proc( parser: ^Parser, - qualifier: symbol.Id, - first, name: token.Token, + left_brace: token.Token, nesting: int, -) -> ast.Expr_Id { - left_brace := advance(parser) + close_message: string, +) -> ([]ast.Expr_Id, token.Token) { parser.delimiter_depth += 1 defer parser.delimiter_depth -= 1 args: [dynamic]ast.Expr_Id @@ -539,9 +538,20 @@ parse_struct_literal :: proc( } right_brace, ok := allow(parser, .Right_Brace) if !ok { - source.add(parser.diagnostics, current(parser).span, "expected '}' after struct literal") + source.add(parser.diagnostics, current(parser).span, close_message) right_brace = left_brace } + return args[:], right_brace +} + +parse_struct_literal :: proc( + parser: ^Parser, + qualifier: symbol.Id, + first, name: token.Token, + nesting: int, +) -> ast.Expr_Id { + left_brace := advance(parser) + args, right_brace := parse_keyed_initializers(parser, left_brace, nesting, "expected '}' after struct literal") return add_expr(parser, ast.Expr{ kind=.Struct_Literal, span=source.Span{file=name.span.file, start=first.span.start, end=right_brace.span.end}, @@ -669,11 +679,32 @@ parse_primary :: proc(parser: ^Parser, nesting: int) -> ast.Expr_Id { advance(parser) payload := ast.INVALID_EXPR end := member.span - if _, ok := allow(parser, .Left_Brace); ok { + if left_brace, ok := allow(parser, .Left_Brace); ok { parser.delimiter_depth += 1 skip_newlines(parser) - if current(parser).kind == .Right_Brace { + if current(parser).kind == .Identifier && peek(parser).kind == .Equal { + parser.delimiter_depth -= 1 + args, right_brace := parse_keyed_initializers(parser, left_brace, nesting, "expected '}' after contextual variant payload") + payload = add_expr(parser, ast.Expr{ + kind=.Struct_Literal, + span=span_from(left_brace.span, right_brace.span), + args=args, + qualifier=symbol.INVALID, + name=symbol.INVALID, + left=ast.INVALID_EXPR, + right=ast.INVALID_EXPR, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + end = right_brace.span + } else if current(parser).kind == .Right_Brace { source.add(parser.diagnostics, current(parser).span, "contextual variant payload requires exactly one expression") + right_brace, close_ok := allow(parser, .Right_Brace) + if !close_ok { + source.add(parser.diagnostics, current(parser).span, "expected '}' after contextual variant payload") + right_brace = member + } + parser.delimiter_depth -= 1 + end = right_brace.span } else { payload = parse_expression_bp(parser, 0, nesting+1) skip_newlines(parser) @@ -683,14 +714,14 @@ parse_primary :: proc(parser: ^Parser, nesting: int) -> ast.Expr_Id { advance(parser) } } + right_brace, close_ok := allow(parser, .Right_Brace) + if !close_ok { + source.add(parser.diagnostics, current(parser).span, "expected '}' after contextual variant payload") + right_brace = member + } + parser.delimiter_depth -= 1 + end = right_brace.span } - right_brace, close_ok := allow(parser, .Right_Brace) - if !close_ok { - source.add(parser.diagnostics, current(parser).span, "expected '}' after contextual variant payload") - right_brace = member - } - parser.delimiter_depth -= 1 - end = right_brace.span } return add_expr(parser, ast.Expr{ kind=.Enum_Literal, @@ -1959,7 +1990,19 @@ parse_function :: proc(parser: ^Parser, name: token.Token, c_abi: bool) { }) } -parse_record_body :: proc(parser: ^Parser, fields: ^[dynamic]types.Field, expected_open: string) -> bool { +parse_record_field_type :: proc(parser: ^Parser, allow_anonymous_struct_payload: bool) -> types.Type { + if allow_anonymous_struct_payload && current(parser).kind == .Keyword_Struct { + return parse_inline_struct_payload_type(parser) + } + return parse_type(parser) +} + +parse_record_body :: proc( + parser: ^Parser, + fields: ^[dynamic]types.Field, + expected_open: string, + allow_anonymous_struct_payload := false, +) -> bool { if _, ok := allow(parser, .Left_Brace); !ok { source.add(parser.diagnostics, current(parser).span, expected_open) return false @@ -1977,7 +2020,7 @@ parse_record_body :: proc(parser: ^Parser, fields: ^[dynamic]types.Field, expect continue } field_name := advance(parser) - field_type := parse_type(parser) + field_type := parse_record_field_type(parser, allow_anonymous_struct_payload) append(fields, types.Field{name=u32(field_name.symbol), type=field_type}) if _, ok := allow(parser, .Comma); ok { skip_newlines(parser) @@ -1991,6 +2034,17 @@ parse_record_body :: proc(parser: ^Parser, fields: ^[dynamic]types.Field, expect return true } +parse_inline_struct_payload_type :: proc(parser: ^Parser) -> types.Type { + advance(parser) + fields: [dynamic]types.Field + fields.allocator = parser.module.allocator + defer delete(fields) + if !parse_record_body(parser, &fields, "expected '{' after anonymous struct payload") { + return types.INVALID + } + return types.struct_anonymous(&parser.module.type_store, fields[:]) +} + parse_inline_union_type :: proc(parser: ^Parser) -> types.Type { advance(parser) valid := true @@ -2013,7 +2067,7 @@ parse_inline_union_type :: proc(parser: ^Parser) -> types.Type { fields: [dynamic]types.Field fields.allocator = parser.module.allocator defer delete(fields) - if !parse_record_body(parser, &fields, "expected '{' after inline union error type") || !valid { + if !parse_record_body(parser, &fields, "expected '{' after inline union error type", true) || !valid { return types.INVALID } tag := synthesize_union_tag(parser, fields[:]) @@ -2060,7 +2114,8 @@ parse_struct :: proc(parser: ^Parser, name: token.Token, c_layout: bool, is_unio fields: [dynamic]types.Field fields.allocator = parser.module.allocator defer delete(fields) - _ = parse_record_body(parser, &fields, "expected '{' after struct fields") + allow_anonymous_struct_payload := is_union && (inferred_tag || types.is_valid(declared_tag)) + _ = parse_record_body(parser, &fields, "expected '{' after struct fields", allow_anonymous_struct_payload) if is_union && (inferred_tag || types.is_valid(declared_tag)) { tag = synthesize_union_tag(parser, fields[:]) } diff --git a/compiler/types/types.odin b/compiler/types/types.odin index 6533393..c70e289 100644 --- a/compiler/types/types.odin +++ b/compiler/types/types.odin @@ -306,6 +306,42 @@ union_anonymous :: proc(store: ^Store, fields: []Field, tag: Type) -> Type { }) } +anonymous_struct_fields_equal :: proc(store: ^Store, item: Node, fields: []Field) -> bool { + if item.field_count != u32(len(fields)) { + return false + } + start := int(item.field_start) + end := start+int(item.field_count) + if start < 0 || end > len(store.fields) { + return false + } + for field, index in fields { + existing := store.fields[start+index] + if existing.name != field.name || existing.type != field.type { + return false + } + } + return true +} + +struct_anonymous :: proc(store: ^Store, fields: []Field) -> Type { + for existing, index in store.nodes { + if existing.kind == .Struct && existing.name == 0 && existing.declared && + !existing.c_layout && !existing.opaque && + anonymous_struct_fields_equal(store, existing, fields) { + return DYNAMIC_START+Type(index) + } + } + start := u32(len(store.fields)) + append(&store.fields, ..fields) + return intern(store, Node{ + kind=.Struct, + field_start=start, + field_count=u32(len(fields)), + declared=true, + }) +} + variant_id :: proc(store: ^Store, name: u32, payload: Type) -> (u16, bool) { for variant in store.variants { if variant.name == name && variant.payload == payload { diff --git a/compiler_tests.odin b/compiler_tests.odin index caf3608..c937cdc 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2568,6 +2568,90 @@ main func() i32 { testing.expect(t, strings.contains(llvm_text, "call void @llvm.memcpy.p0.p0.i64")) } +@(test) +anonymous_struct_payloads_and_keyed_payload_sugar_compile :: proc(t: ^testing.T) { + text := `PayloadError :: union(enum) { + not_found struct { + path i32 + line i32 + } + wrapped struct { + path i32 + line i32 + } + scalar i32 + empty void +} +OtherError :: union(enum) { + not_found struct { + path i32 + line i32 + } + other void +} +BothError :: alias PayloadError | OtherError +accept func(value PayloadError) i32 { + match value { + .not_found |info|: return info.path + info.line + .wrapped |info|: return info.path * 10 + info.line + .scalar |n|: return n + .empty: return 0 + } +} +with_payload func(value i32) i32 ! PayloadError { + if (value == 0) return .not_found{path = 4, line = 5} + if (value == 1) return .scalar{7} + return value +} +inline_payload func(value i32) i32 ! union(enum) { + inline_bad struct { + code i32 + line i32 + } +} { + if (value == 0) return .inline_bad{code = 6, line = 7} + return value +} +main func() i32 { + e PayloadError = .not_found{path = 1, line = 2} + a :: accept(e) + b :: accept(.wrapped{line = 4, path = 3}) + c :: with_payload(0) catch |err| { + match err { + .not_found |info|: yield info.path + info.line + .wrapped |info|: yield info.path + info.line + .scalar |n|: yield n + .empty: yield 0 + } + } + d :: inline_payload(0) catch |err| { + match err { + .inline_bad |info|: yield info.code + info.line + } + } + return a + b + c + d - 59 +} +` + source_file := source.Source{path="anonymous_payloads.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) + ir_module := lower.lower(&hir_module) + defer ir.destroy_module(&ir_module) + llvm_text := llvm.emit(&ir_module, &diagnostics, &symbols) + defer delete(llvm_text) + + testing.expect_value(t, len(diagnostics.items), 0) + testing.expect(t, len(llvm_text) > 0) +} + @(test) inline_fallible_error_types_compile :: proc(t: ^testing.T) { text := `inline_enum func(value i32) i32 ! enum { @@ -2689,6 +2773,77 @@ main func() i32 { testing.expect(t, found_no_context) } +@(test) +anonymous_struct_payloads_reject_bad_forms :: proc(t: ^testing.T) { + text := `Bad :: union(enum) { + payload struct { + x i32 + y i32 + } + scalar i32 +} +unknown func() Bad { + return .payload{x = 1, z = 2} +} +duplicate func() Bad { + return .payload{x = 1, x = 2, y = 3} +} +missing func() Bad { + return .payload{x = 1} +} +scalar_keyed func() Bad { + return .scalar{value = 1} +} +A :: union(enum) { + dup struct { + x i32 + } +} +B :: union(enum) { + dup struct { + x i64 + } +} +Conflict :: alias A | B +main func() i32 { + _ = unknown() + _ = duplicate() + _ = missing() + _ = scalar_keyed() + return 0 +} +` + source_file := source.Source{path="bad_anonymous_payloads.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_unknown := false + found_duplicate := false + found_missing := false + found_keyed_non_struct := false + found_conflict := false + for diagnostic in diagnostics.items { + found_unknown = found_unknown || strings.contains(diagnostic.message, "unknown struct field 'z'") + found_duplicate = found_duplicate || strings.contains(diagnostic.message, "duplicate initializer for struct field 'x'") + found_missing = found_missing || strings.contains(diagnostic.message, "missing initializer for struct field 'y'") + found_keyed_non_struct = found_keyed_non_struct || strings.contains(diagnostic.message, "keyed contextual payload requires a struct payload") + found_conflict = found_conflict || strings.contains(diagnostic.message, "same variant name") + } + testing.expect(t, found_unknown) + testing.expect(t, found_duplicate) + testing.expect(t, found_missing) + testing.expect(t, found_keyed_non_struct) + testing.expect(t, found_conflict) +} + @(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 7c55305..d1f947e 100644 --- a/examples/programs/errors/main.bro +++ b/examples/programs/errors/main.bro @@ -5,6 +5,10 @@ BasicError :: enum { DetailError :: union(enum) { code i32 + info struct { + code i32 + extra i32 + } empty void } @@ -44,6 +48,7 @@ via_try func(value i32) i32 ! BasicError { with_detail func(value i32) i32 ! DetailError { if (value == 0) return .code{5} if (value == 1) return .empty + if (value == 3) return .info{code = 8, extra = 9} return value } @@ -65,8 +70,9 @@ catch_basic func(value i32) i32 { catch_detail func(value i32) i32 { return with_detail(value) catch |e| { match e { - .code |n|: yield n + 30 - .empty: yield 40 + .code |n|: yield n + 30 + .info |info|: yield info.code + info.extra + 30 + .empty: yield 40 } } } @@ -74,10 +80,11 @@ catch_detail func(value i32) i32 { 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 + .bad: yield 50 + .worse: yield 51 + .code |n|: yield n + .info |info|: yield info.code + info.extra + .empty: yield 52 } } } @@ -143,8 +150,15 @@ main func() i32 { .inline_empty: yield 80 } } + p :: with_detail(3) catch |e| { + match e { + .code |value|: yield value + .info |info|: yield info.code + info.extra + .empty: yield 0 + } + } - acc = acc + a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + acc = acc + a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p acc = acc + pick(.left) r Right = .right acc = acc + pick(r) @@ -154,6 +168,6 @@ main func() i32 { acc = acc + payload(empty) acc = acc + payload(.a{9}) - # 7 + 5 + 5 + 11 + 13 + 2 + 21 + 22 + 35 + 40 + 6 + 50 + 51 + 60 + 76 + 10 + 20 + 8 + 3 + 9 = 454 - return acc - 454 + # 7 + 5 + 5 + 11 + 13 + 2 + 21 + 22 + 35 + 40 + 6 + 50 + 51 + 60 + 76 + 17 + 10 + 20 + 8 + 3 + 9 = 471 + return acc - 471 }