From c82f070d55ea52abffc0d6574af2e6c6f4c060fd Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 29 Jun 2026 19:52:05 +0200 Subject: [PATCH] void-payloads, multi-pattern arms, and range patterns (match statements) --- TODO.md | 36 ++++ compiler/ast/ast.odin | 11 +- compiler/checker/checker.odin | 289 ++++++++++++++++++++++++------- compiler/llvm/llvm.odin | 21 ++- compiler/lower/lower.odin | 4 +- compiler/parser/parser.odin | 41 +++-- compiler_tests.odin | 125 +++++++++++++ examples/programs/match/main.bro | 78 +++++++-- 8 files changed, 499 insertions(+), 106 deletions(-) diff --git a/TODO.md b/TODO.md index ecb5e17..0e3aa62 100644 --- a/TODO.md +++ b/TODO.md @@ -504,6 +504,42 @@ is not a runtime field type yet, so 21.5 can't declare them, though the no-capture arm form is already wired; and multi-pattern arms (`.a, .b:`) / range patterns +22.5. `void`-payloads and multi-pattern arms / range patterns in match statements (implemented; see below) + - **void-payload variants**: a tagged union may declare a `void`-payload variant + (`quit void`). As in Zig, a void field carries no runtime value — it is allowed only on a + tagged union (the record-decl check skips the runtime-value requirement for it), contributes + nothing to the layout (`size` 0 / `align` 1, so it is never the carrier), and is constructed + with the **bare-key** literal `T{ variant }` (no `= value`). Construction stores only the tag; + codegen skips the payload store. Matched with a plain no-capture arm (`.quit:`); a capture on a + void variant, a `= value` on a void variant, a bare key on a non-void field, and a direct + `x.quit` payload read are all diagnosed + - **multi-pattern arms**: an arm may list several patterns (`.a, .b:` / `0, 1, 2:`); the AST + `Match_Arm` now carries a `patterns` list and the checker ORs their dispatch conditions. A + capturing multi-pattern arm over a tagged union is allowed when every listed variant has the + same payload type (Zig parity — payloads share the carrier offset, so it is one read); + mismatched payload types are a "capture group with incompatible types" error + - **range patterns**: a scalar arm may be a range (`0..10:` / `0..=10:`), desugared to + `key >= lo and key <(=) hi` (existing `.Ge`/`.Le`/`.Lt`/`.And` HIR); a range pattern on an + enum/union subject is rejected + - **pointer captures**: `|@cap|` binds a pointer into the subject's payload (mutate in place), + reusing the for-loop `@`-capture and `pointer_capture` flag; mutability follows the subject. It + requires an addressable subject — verified to need **no lowering/codegen change**: the subject + is spilled as `&subject` and captures route through a `Deref` (`lower_location(Deref)` is the + pointee address), so `Address(Field(Deref(ptr)))` aliases the original storage + - the only new codegen is the void construction skip (one `llvm` site) plus a one-line `lower` + guard so a void variant's absent payload operand is not lowered into a trapping recovery value; + everything else is parser + checker desugar + - deferred (`// ponytail:` follow-ups): contextual void construction (`e Event = .quit`) needs + enum-literal→union coercion (milestone 23); Zig `inline .a, .b => |v|` per-tag comptime + captures need monomorphization. Separately, a **call expression directly as a match subject** + (`match get()`) is a pre-existing gap (assign to a variable first, as the spec examples do); + the rvalue pointer-capture guard is defensive for when that lands + +22.6. (`// ponytail:` follow-ups): contextual void construction (`e Event = .quit`) needs + enum-literal→union coercion. Separately, a **call expression directly as a match subject** + (`match get()`) is a pre-existing gap (assign to a variable first, as the spec examples do); + the rvalue pointer-capture guard is defensive for when that lands + 23. error types (see below) 24. dynamic heap allocation diff --git a/compiler/ast/ast.odin b/compiler/ast/ast.odin index d21e0fb..9460f1b 100644 --- a/compiler/ast/ast.odin +++ b/compiler/ast/ast.odin @@ -178,10 +178,14 @@ Stmt :: struct { // `Defer` statements use `update` as the deferred statement (which may itself // be a `Block`). // `Match` statements use `expr` as the subject and `body` as the list of arm - // statements (each a `Match_Arm`). A `Match_Arm` uses `expr` as its pattern - // (`INVALID_EXPR` marks the `else` arm), `captures` for the optional payload - // capture (0 or 1 name, tagged-union variants only), and `body` as the arm body. + // statements (each a `Match_Arm`). A `Match_Arm` uses `patterns` as its pattern + // list (empty marks the `else` arm; more than one is a multi-pattern arm), + // `captures` for the optional payload capture (0 or 1 name, tagged-union variants + // only) with `pointer_capture` distinguishing `|@cap|` from `|cap|`, and `body` + // as the arm body. captures: []symbol.Id, + // `Match_Arm` pattern list; empty ⇒ the `else` arm. + patterns: []Expr_Id, guard: Expr_Id, body: []Stmt_Id, else_body: []Stmt_Id, @@ -303,6 +307,7 @@ destroy_module :: proc(module: ^Module) { } for statement in module.statements { delete(statement.captures, module.allocator) + delete(statement.patterns, 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 d129500..32c0494 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -788,8 +788,11 @@ mark_block_imports_used :: proc(checker: ^Checker, statements: []ast.Stmt_Id, fi mark_block_imports_used(checker, deferred[:], file) case .Match, .Match_Arm: // `Match` carries the subject in `expr` and arms in `body`; each `Match_Arm` - // carries its pattern in `expr` and the arm body in `body`. + // carries its patterns in `patterns` and the arm body in `body`. mark_expr_imports_used(checker, statement.expr, file) + for pattern in statement.patterns { + mark_expr_imports_used(checker, pattern, file) + } mark_block_imports_used(checker, statement.body, file) case .Break, .Continue: case .Invalid: @@ -1048,8 +1051,14 @@ validate_type_nodes :: proc(checker: ^Checker) { "c_struct definitions require at least one field", ) } + // A tagged union may carry `void`-payload variants (`.quit void`): the + // variant has no runtime value, only a tag. Allowed only here, not for + // structs, untagged unions, or c_structs. + tagged_union := item.kind == .Union && types.is_enum(item.child, &checker.module.types) for field in types.fields_for(&checker.module.types, id) { - if !types.is_runtime_value(field.type, &checker.module.types) { + if tagged_union && types.is_void(field.type) { + // void variant: no payload to validate. + } else if !types.is_runtime_value(field.type, &checker.module.types) { source.add( checker.diagnostics, source.Span{}, @@ -3202,6 +3211,10 @@ build_compound_expr :: proc( id := source.addf(checker.diagnostics, expr.span, "unknown struct field '%s'", symbol_text(checker, expr.name)) return invalid_hir_expr(checker, expr.span, id) } + if types.is_void(field.type) { + id := source.addf(checker.diagnostics, expr.span, "variant '%s' has no payload to read", symbol_text(checker, expr.name)) + return invalid_hir_expr(checker, expr.span, id) + } return add_hir_expr(checker, hir.Expr{ kind=.Field, span=expr.span, type=field.type, integer=i64(index), left=base, target=hir.INVALID_REF, right=hir.INVALID_EXPR, diagnostic=source.INVALID_DIAGNOSTIC, @@ -3402,8 +3415,20 @@ build_compound_expr :: proc( } initialized[index] = true value_index := 0 if union_record else index - values[value_index] = build_nested_expr(checker, keyed_expr.left, locals, global_reads, calls, field.type, pkg, file) - values[value_index] = coerce_expr(checker, values[value_index], field.type, keyed_expr.span) + is_void_field := union_record && types.is_void(field.type) + if keyed_expr.left == ast.INVALID_EXPR { + // Bare key `T{ variant }`: valid only to construct a void-payload + // variant (no value); the payload slot stays INVALID_EXPR. + if !is_void_field { + source.addf(checker.diagnostics, keyed_expr.span, "field '%s' requires a value", symbol_text(checker, keyed_expr.name)) + } + } else { + if is_void_field { + source.addf(checker.diagnostics, keyed_expr.span, "void variant '%s' takes no value", symbol_text(checker, keyed_expr.name)) + } + values[value_index] = build_nested_expr(checker, keyed_expr.left, locals, global_reads, calls, field.type, pkg, file) + values[value_index] = coerce_expr(checker, values[value_index], field.type, keyed_expr.span) + } } if !union_record { for field, index in fields { @@ -3417,16 +3442,21 @@ build_compound_expr :: proc( } active_field: i64 if union_record { - if values[0] == hir.INVALID_EXPR { - delete(values, checker.allocator) - return invalid_hir_expr(checker, expr.span, source.add(checker.diagnostics, expr.span, "union literal requires a known field"), struct_type) - } + // The active variant is the one initialized field. Its payload slot + // (`values[0]`) may legitimately be INVALID_EXPR for a void variant, so + // detect "no field" via `initialized`, not the payload value. + found_any := false for value, index in initialized { if value { active_field = i64(index) + found_any = true break } } + if !found_any { + delete(values, checker.allocator) + return invalid_hir_expr(checker, expr.span, source.add(checker.diagnostics, expr.span, "union literal requires a known field"), struct_type) + } } return add_hir_expr(checker, hir.Expr{ kind=.Struct, span=expr.span, type=struct_type, args=values, integer=active_field, @@ -5347,6 +5377,32 @@ Match_Built_Arm :: struct { terminal: bool, } +// match_subject_location yields a fresh location expr for the `match` subject: a direct +// read of the value temp, or a deref of the pointer temp when an arm pointer-captures (so +// captures alias the original storage). Either way its lowered address is the subject's. +match_subject_location :: proc(checker: ^Checker, subj_local: hir.Local_Id, is_pointer: bool, subject_type, ptr_type: types.Type, span: source.Span) -> hir.Expr_Id { + if !is_pointer { + return slot_read(checker, subj_local, subject_type, span) + } + return add_hir_expr(checker, hir.Expr{ + kind = .Deref, span = span, type = subject_type, + left = slot_read(checker, subj_local, ptr_type, span), + target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, + }) +} + +// match_or ORs a fresh dispatch comparison into an arm's accumulating condition (for a +// multi-pattern arm), or returns it directly for the first pattern. +match_or :: proc(checker: ^Checker, condition, cmp: hir.Expr_Id, span: source.Span) -> hir.Expr_Id { + if condition == hir.INVALID_EXPR { + return cmp + } + return add_hir_expr(checker, hir.Expr{ + kind = .Or, span = span, type = types.BOOL, + left = condition, right = cmp, target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, + }) +} + // emit_match desugars a `match` into a single subject spill, one dispatch key read, and // an `if`/`else if` chain. `as_value` (with `slot`/`slot_type`) routes each arm body // through the value-branch machinery so the construct produces a value; otherwise arm @@ -5374,8 +5430,10 @@ emit_match :: proc( return false } - // 1. Subject, spilled into an addressable temp so the tag read and any payload - // captures reference one evaluation. + // 1. Subject. A pointer capture (`|@cap|`) must alias the original storage, so when + // any arm requests one we spill the subject's *address* (it must be an addressable + // lvalue) and route reads through a deref; otherwise we spill the value as a copy. + // Either spill evaluates the subject exactly once. subject := build_expr(checker, statement.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file) subject_type := checker.module.exprs[subject].type if checker.module.exprs[subject].kind == .Invalid { @@ -5392,24 +5450,56 @@ emit_match :: proc( "'match' subject must be a tagged union, enum, or scalar value, not '%s'", type_label(checker, subject_type))) } + ok := true + + wants_pointer := false + for arm_id in statement.body { + arm := checker.ast_module.statements[arm_id] + if arm.kind == .Match_Arm && arm.pointer_capture { + wants_pointer = true + break + } + } + subj_is_pointer := false + subj_writable := false + ptr_type := types.INVALID + if wants_pointer && is_tagged { + if hir_is_location(checker, subject) { + subj_is_pointer = true + subj_writable = hir_location_writable(checker, subject, ctx.locals^[:]) + ptr_type = types.pointer(store, subject_type, subj_writable, false) + } else { + source.add(checker.diagnostics, span, "a pointer capture requires an addressable 'match' subject (bind it to a variable first)") + ok = false + } + } + + spill_type := ptr_type if subj_is_pointer else subject_type + spill_value := subject + if subj_is_pointer { + spill_value = add_hir_expr(checker, hir.Expr{ + kind = .Address, span = span, type = ptr_type, left = subject, + target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, + }) + } subj_local := hir.local_id(len(ctx.hir_locals^)) - append(ctx.hir_locals, hir.Local{name = checker.sink_symbol, type = subject_type, mutable = false}) + append(ctx.hir_locals, hir.Local{name = checker.sink_symbol, type = spill_type, mutable = false}) append(out, hir.stmt_id(len(checker.module.statements))) append(&checker.module.statements, hir.Stmt{ - kind = .Declaration, span = span, local = subj_local, expr = subject, + kind = .Declaration, span = span, local = subj_local, expr = spill_value, diagnostic = source.INVALID_DIAGNOSTIC, }) // 2. Dispatch key: a tagged union reads its discriminant into its own temp; an enum // or scalar compares the subject directly. key_local := subj_local - key_type := subject_type + key_type := spill_type tag_enum := types.INVALID if is_tagged { tag_enum = types.union_tag_enum(subject_type, store) tag_read := add_hir_expr(checker, hir.Expr{ kind = .Union_Tag, span = span, type = tag_enum, - left = slot_read(checker, subj_local, subject_type, span), + left = match_subject_location(checker, subj_local, subj_is_pointer, subject_type, ptr_type, span), target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, }) tag_local := hir.local_id(len(ctx.hir_locals^)) @@ -5423,7 +5513,8 @@ emit_match :: proc( key_type = tag_enum } - // 3. Build each arm (forward, for source-order diagnostics). + // 3. Build each arm (forward, for source-order diagnostics). An arm's `patterns` may + // list several alternatives (`.a, .b:` / `0, 1:`); their conditions are OR'd. built: [dynamic]Match_Built_Arm built.allocator = checker.allocator defer delete(built) @@ -5431,7 +5522,6 @@ emit_match :: proc( covered.allocator = checker.allocator defer delete(covered) has_else := false - ok := true for arm_id in statement.body { arm := checker.ast_module.statements[arm_id] @@ -5443,7 +5533,7 @@ emit_match :: proc( source.add(checker.diagnostics, arm.span, "arms after 'else' are unreachable") ok = false } - is_else := arm.expr == ast.INVALID_EXPR + is_else := len(arm.patterns) == 0 condition := hir.INVALID_EXPR field_index := -1 payload_type := types.INVALID @@ -5456,69 +5546,123 @@ emit_match :: proc( } has_else = true } else if is_tagged || is_enum_subject { - pattern := checker.ast_module.exprs[arm.expr] - if pattern.kind != .Enum_Literal { - source.add(checker.diagnostics, arm.span, "an enum or tagged-union 'match' arm must be a '.variant' pattern") - ok = false - continue - } - if contains_name(covered[:], pattern.name) { - source.addf(checker.diagnostics, arm.span, "duplicate 'match' arm for '.%s'", symbol_text(checker, pattern.name)) - ok = false - } else { - append(&covered, pattern.name) - } - if is_tagged { - index, field, found := find_struct_field(checker, subject_type, pattern.name) - if !found { - source.addf(checker.diagnostics, arm.span, "unknown variant '.%s' on '%s'", symbol_text(checker, pattern.name), type_label(checker, subject_type)) + // The capture payload (if any) must be one type across every listed variant. + capture_field := -1 + capture_payload := types.INVALID + capture_conflict := types.INVALID + member_enum := tag_enum if is_tagged else subject_type + for pat_id in arm.patterns { + pattern := checker.ast_module.exprs[pat_id] + if pattern.kind == .Range { + source.add(checker.diagnostics, arm.span, "range patterns only apply to scalar 'match' subjects") ok = false continue } - field_index = index - payload_type = field.type - member := enum_member_hir(checker, tag_enum, pattern.name, arm.span) - condition = add_hir_expr(checker, hir.Expr{ + if pattern.kind != .Enum_Literal { + source.add(checker.diagnostics, arm.span, "an enum or tagged-union 'match' arm must be a '.variant' pattern") + ok = false + continue + } + if contains_name(covered[:], pattern.name) { + source.addf(checker.diagnostics, arm.span, "duplicate 'match' arm for '.%s'", symbol_text(checker, pattern.name)) + ok = false + } else { + append(&covered, pattern.name) + } + if is_tagged { + index, field, found := find_struct_field(checker, subject_type, pattern.name) + if !found { + source.addf(checker.diagnostics, arm.span, "unknown variant '.%s' on '%s'", symbol_text(checker, pattern.name), type_label(checker, subject_type)) + ok = false + continue + } + if capture_field < 0 { + capture_field = index + capture_payload = field.type + } else if !types.equal(capture_payload, field.type) { + capture_conflict = field.type + } + } else { + if _, found := find_enum_member(checker, subject_type, pattern.name); !found { + source.addf(checker.diagnostics, arm.span, "unknown member '.%s' on '%s'", symbol_text(checker, pattern.name), type_label(checker, subject_type)) + ok = false + continue + } + } + member := enum_member_hir(checker, member_enum, pattern.name, arm.span) + cmp := add_hir_expr(checker, hir.Expr{ kind = .Eq, span = arm.span, type = types.BOOL, left = slot_read(checker, key_local, key_type, arm.span), right = member, target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, }) - } else { - if has_capture { + condition = match_or(checker, condition, cmp, arm.span) + } + if has_capture { + if !is_tagged { source.add(checker.diagnostics, arm.span, "only tagged-union variants can capture a payload") ok = false - } - if _, found := find_enum_member(checker, subject_type, pattern.name); !found { - source.addf(checker.diagnostics, arm.span, "unknown member '.%s' on '%s'", symbol_text(checker, pattern.name), type_label(checker, subject_type)) + } else if types.is_valid(capture_conflict) { + source.addf(checker.diagnostics, arm.span, "capture group with incompatible types '%s' and '%s'", + type_label(checker, capture_payload), type_label(checker, capture_conflict)) ok = false - continue + } else if types.is_void(capture_payload) { + source.add(checker.diagnostics, arm.span, "this variant has a void payload; there is nothing to capture") + ok = false + } else { + field_index = capture_field + payload_type = capture_payload } - member := enum_member_hir(checker, subject_type, pattern.name, arm.span) - condition = add_hir_expr(checker, hir.Expr{ - kind = .Eq, span = arm.span, type = types.BOOL, - left = slot_read(checker, key_local, key_type, arm.span), right = member, - target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, - }) } } else { + // Scalar subject: each pattern is a literal or a range, compared to the subject. if has_capture { source.add(checker.diagnostics, arm.span, "only tagged-union variants can capture a payload") ok = false } - pattern := build_expr(checker, arm.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, subject_type, ctx.pkg, ctx.file) - pattern = coerce_expr(checker, pattern, subject_type, arm.span) - if checker.module.exprs[pattern].kind == .Invalid { - ok = false - continue + for pat_id in arm.patterns { + pat_ast := checker.ast_module.exprs[pat_id] + cmp := hir.INVALID_EXPR + if pat_ast.kind == .Range { + lo := build_expr(checker, pat_ast.left, ctx.locals^[:], ctx.global_reads, ctx.calls, subject_type, ctx.pkg, ctx.file) + lo = coerce_expr(checker, lo, subject_type, arm.span) + hi := build_expr(checker, pat_ast.right, ctx.locals^[:], ctx.global_reads, ctx.calls, subject_type, ctx.pkg, ctx.file) + hi = coerce_expr(checker, hi, subject_type, arm.span) + if checker.module.exprs[lo].kind == .Invalid || checker.module.exprs[hi].kind == .Invalid { + ok = false + continue + } + ge := add_hir_expr(checker, hir.Expr{ + kind = .Ge, span = arm.span, type = types.BOOL, + left = slot_read(checker, key_local, key_type, arm.span), right = lo, + target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, + }) + hi_cmp := add_hir_expr(checker, hir.Expr{ + kind = .Le if pat_ast.integer == 1 else .Lt, span = arm.span, type = types.BOOL, + left = slot_read(checker, key_local, key_type, arm.span), right = hi, + target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, + }) + cmp = add_hir_expr(checker, hir.Expr{ + kind = .And, span = arm.span, type = types.BOOL, + left = ge, right = hi_cmp, target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, + }) + } else { + pattern := build_expr(checker, pat_id, ctx.locals^[:], ctx.global_reads, ctx.calls, subject_type, ctx.pkg, ctx.file) + pattern = coerce_expr(checker, pattern, subject_type, arm.span) + if checker.module.exprs[pattern].kind == .Invalid { + ok = false + continue + } + cmp = add_hir_expr(checker, hir.Expr{ + kind = .Eq, span = arm.span, type = types.BOOL, + left = slot_read(checker, key_local, key_type, arm.span), right = pattern, + target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, + }) + } + condition = match_or(checker, condition, cmp, arm.span) } - condition = add_hir_expr(checker, hir.Expr{ - kind = .Eq, span = arm.span, type = types.BOOL, - left = slot_read(checker, key_local, key_type, arm.span), right = pattern, - target = hir.INVALID_REF, diagnostic = source.INVALID_DIAGNOSTIC, - }) } - arm_body, body_ok := build_match_arm_body(ctx, arm, subject_type, subj_local, field_index, payload_type, as_value, slot, slot_type, span) + arm_body, body_ok := build_match_arm_body(ctx, arm, subject_type, subj_local, subj_is_pointer, ptr_type, subj_writable, field_index, payload_type, as_value, slot, slot_type, span) if !body_ok { ok = false } @@ -5622,6 +5766,9 @@ build_match_arm_body :: proc( arm: ast.Stmt, subject_type: types.Type, subj_local: hir.Local_Id, + subj_is_pointer: bool, + ptr_type: types.Type, + subj_writable: bool, field_index: int, payload_type: types.Type, as_value: bool, @@ -5637,17 +5784,29 @@ build_match_arm_body :: proc( if len(arm.captures) > 0 && field_index >= 0 { capture := arm.captures[0] if capture != checker.sink_symbol { - cap_local := hir.local_id(len(ctx.hir_locals^)) - append(ctx.hir_locals, hir.Local{name = capture, type = payload_type, mutable = false}) - append(ctx.locals, Build_Local{name = capture, type = payload_type, mutable = false, id = cap_local}) + // The payload sits at the subject's shared carrier offset. A value capture + // loads it; a `|@cap|` capture binds a pointer to it (mutability follows the + // subject), aliasing the original storage via the subject location. field_read := add_hir_expr(checker, hir.Expr{ kind = .Field, span = span, type = payload_type, integer = i64(field_index), - left = slot_read(checker, subj_local, subject_type, span), + left = match_subject_location(checker, subj_local, subj_is_pointer, subject_type, ptr_type, span), target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, }) + cap_type := payload_type + cap_value := field_read + if arm.pointer_capture { + cap_type = types.pointer(&checker.module.types, payload_type, subj_writable, false) + cap_value = add_hir_expr(checker, hir.Expr{ + kind = .Address, span = span, type = cap_type, left = field_read, + target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, + }) + } + cap_local := hir.local_id(len(ctx.hir_locals^)) + append(ctx.hir_locals, hir.Local{name = capture, type = cap_type, mutable = false}) + append(ctx.locals, Build_Local{name = capture, type = cap_type, mutable = false, id = cap_local}) append(&result, hir.stmt_id(len(checker.module.statements))) append(&checker.module.statements, hir.Stmt{ - kind = .Declaration, span = span, local = cap_local, expr = field_read, + kind = .Declaration, span = span, local = cap_local, expr = cap_value, diagnostic = source.INVALID_DIAGNOSTIC, }) } diff --git a/compiler/llvm/llvm.odin b/compiler/llvm/llvm.odin index 29bf367..da1601a 100644 --- a/compiler/llvm/llvm.odin +++ b/compiler/llvm/llvm.odin @@ -704,8 +704,11 @@ emit_instruction_stream :: proc( if item.kind == .Union { fields := types.fields_for(&emitter.module.types, instruction.type) field_index := int(instruction.integer) + // A void-payload variant carries no value (`T{ variant }`): only the tag + // is stored, so there is no operand to validate or write. + void_payload := field_index >= 0 && field_index < len(fields) && types.is_void(fields[field_index].type) if field_index < 0 || field_index >= len(fields) || - !valid_value(instructions, instruction.args[0], fields[field_index].type, &emitter.module.types) { + (!void_payload && !valid_value(instructions, instruction.args[0], fields[field_index].type, &emitter.module.types)) { emit_recovery_value(emitter, instruction_index, instruction, "invalid union aggregate operands") continue } @@ -725,13 +728,17 @@ emit_instruction_stream :: proc( } } fmt.sbprintf(&emitter.builder, " store %s %d, ptr %%union_slot%d\n", llvm_type(tag_enum, &emitter.module.types), tag_value, instruction_index) - offset := types.union_payload_offset(instruction.type, &emitter.module.types, emitter.module.target) - fmt.sbprintf(&emitter.builder, " %%union_payload%d = getelementptr i8, ptr %%union_slot%d, i64 %d\n", instruction_index, instruction_index, offset) - payload_ptr = fmt.tprintf("%%union_payload%d", instruction_index) + if !void_payload { + offset := types.union_payload_offset(instruction.type, &emitter.module.types, emitter.module.target) + fmt.sbprintf(&emitter.builder, " %%union_payload%d = getelementptr i8, ptr %%union_slot%d, i64 %d\n", instruction_index, instruction_index, offset) + payload_ptr = fmt.tprintf("%%union_payload%d", instruction_index) + } + } + if !void_payload { + fmt.sbprintf(&emitter.builder, " store %s ", llvm_type(fields[field_index].type, &emitter.module.types)) + write_operand(&emitter.builder, instructions, instruction.args[0], fields[field_index].type, &emitter.module.types) + fmt.sbprintf(&emitter.builder, ", ptr %s\n", payload_ptr) } - fmt.sbprintf(&emitter.builder, " store %s ", llvm_type(fields[field_index].type, &emitter.module.types)) - write_operand(&emitter.builder, instructions, instruction.args[0], fields[field_index].type, &emitter.module.types) - fmt.sbprintf(&emitter.builder, ", ptr %s\n", payload_ptr) fmt.sbprintf(&emitter.builder, " %%v%d = load %s, ptr %%union_slot%d\n", instruction_index, type_name, instruction_index) continue } diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index 3170a6b..f386705 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -211,7 +211,9 @@ lower_compound_expr :: proc(state: ^State, expr_id: hir.Expr_Id) -> ir.Instructi case .Array, .Struct: args := make([]ir.Instruction_Id, len(expr.args), state.allocator) for arg, index in expr.args { - args[index] = lower_nested_expr(state, arg) + // A void union variant (`T{ variant }`) has no payload operand; leave it + // invalid so codegen emits only the tag, not a trapping recovery value. + args[index] = lower_nested_expr(state, arg) if arg != hir.INVALID_EXPR else ir.INVALID_INSTRUCTION } return append_instruction(state, ir.Instruction{ op=.Aggregate, span=expr.span, type=expr.type, args=args, integer=expr.integer, diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index 6f77f45..aa99e35 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -477,14 +477,18 @@ parse_struct_literal :: proc( break } advance(parser) - if _, ok := allow(parser, .Equal); !ok { - source.add(parser.diagnostics, current(parser).span, "expected '=' after struct field name") + // A bare key (`T{ variant }`, no `= value`) constructs a void-payload union + // variant; the checker validates that the field actually has a void type. + value := ast.INVALID_EXPR + key_end := field.span + if _, ok := allow(parser, .Equal); ok { + skip_newlines(parser) + value = parse_expression_bp(parser, 0, nesting+1) + key_end = parser.module.exprs[value].span } - skip_newlines(parser) - value := parse_expression_bp(parser, 0, nesting+1) append(&args, add_expr(parser, ast.Expr{ kind=.Keyed, - span=span_from(field.span, parser.module.exprs[value].span), + span=span_from(field.span, key_end), name=field.symbol, left=value, right=ast.INVALID_EXPR, @@ -1544,20 +1548,33 @@ parse_arm_body :: proc(parser: ^Parser) -> []ast.Stmt_Id { return single } -// parse_match_arm parses one ` [|capture|]: ` arm (or `else: `). -// The pattern is `INVALID_EXPR` for `else`; `captures` holds the optional 0-or-1 -// payload capture name (tagged-union variants only). +// parse_match_arm parses one ` [|[@]capture|]: ` arm (or +// `else: `). `patterns` is empty for `else`, one expr for a single pattern, or +// several for a multi-pattern arm; `captures` holds the optional 0-or-1 payload capture +// name with `pointer_capture` set for the `|@cap|` form (validated in the checker). parse_match_arm :: proc(parser: ^Parser) -> ast.Stmt_Id { start := current(parser).span - pattern := ast.INVALID_EXPR + patterns: [dynamic]ast.Expr_Id + patterns.allocator = parser.module.allocator captures: [dynamic]symbol.Id captures.allocator = parser.module.allocator + pointer_capture := false if _, is_else := allow(parser, .Keyword_Else); !is_else { saved := parser.no_struct_literal parser.no_struct_literal = true - pattern = parse_expression(parser) + append(&patterns, parse_expression(parser)) + for { + if _, ok := allow(parser, .Comma); !ok { + break + } + skip_newlines(parser) + append(&patterns, parse_expression(parser)) + } parser.no_struct_literal = saved if _, ok := allow(parser, .Pipe); ok { + if _, at_ok := allow(parser, .At); at_ok { + pointer_capture = true + } name_tok := current(parser) if name_tok.kind == .Identifier || name_tok.kind == .Underscore { advance(parser) @@ -1578,8 +1595,10 @@ parse_match_arm :: proc(parser: ^Parser) -> ast.Stmt_Id { append(&parser.module.statements, ast.Stmt{ kind=.Match_Arm, span=span_from(start, previous(parser).span), - expr=pattern, + expr=ast.INVALID_EXPR, + patterns=patterns[:], captures=captures[:], + pointer_capture=pointer_capture, body=body, target=ast.INVALID_EXPR, update=ast.INVALID_STMT, diff --git a/compiler_tests.odin b/compiler_tests.odin index 39c7733..a54656f 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2402,6 +2402,131 @@ main :: func() i32 { testing.expect(t, found_unknown) } +@(test) +match_range_arm_emits_bounds :: proc(t: ^testing.T) { + // A scalar range arm `lo..hi:` desugars to `key >= lo and key < hi` (inclusive uses + // `<=`), emitted as signed integer comparisons for an i32 subject. + text := `main :: func() i32 { + n i32 = 5 + out i32 = 0 + match n { + 0..10: out = 1 + 10..=20: out = 2 + else: out = 3 + } + return out +} +` + 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) + 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, strings.contains(llvm_text, "icmp sge i32")) // key >= lo + testing.expect(t, strings.contains(llvm_text, "icmp slt i32")) // key < hi (exclusive) + testing.expect(t, strings.contains(llvm_text, "icmp sle i32")) // key <= hi (inclusive) +} + +@(test) +match_extended_misuse_is_diagnosed :: proc(t: ^testing.T) { + // Five rejected forms from milestone 22.5: (1) a capture on a void variant, (2) a value + // given to a void variant in construction, (3) a bare key on a non-void field, (4) a + // range pattern on an enum subject, and (5) a multi-pattern capture whose variants have + // different payload types. (Pointer capture on an rvalue subject is also rejected, but + // match-on-call-result is a separate pre-existing gap so it isn't exercised here.) + text := `Animal :: enum { + dog + cat + bird +} +Point :: struct { + x i32 + y i32 +} +Box :: union(enum) { + point Point + count i32 + empty void +} +void_capture :: func(b Box) i32 { + match b { + .point |p|: { return p.x } + .count |c|: { return c } + .empty |x|: { return 0 } + } + return 0 +} +void_value :: func() i32 { + b Box = Box{ empty = 5 } + return 0 +} +bare_on_nonvoid :: func() i32 { + b Box = Box{ count } + return 0 +} +range_on_enum :: func(a Animal) i32 { + match a { + 0..2: { return 1 } + else: { return 0 } + } + return 0 +} +incompatible_capture :: func(b Box) i32 { + match b { + .point, .count |v|: { return 0 } + .empty: { return 0 } + } + return 0 +} +main :: func() i32 { + b Box = Box{ count = 1 } + return void_capture(b) + void_value() + bare_on_nonvoid() + range_on_enum(.dog) + + incompatible_capture(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) + 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_void_capture := false + found_void_value := false + found_bare := false + found_range_enum := false + found_incompatible := false + for diagnostic in diagnostics.items { + found_void_capture = found_void_capture || strings.contains(diagnostic.message, "void payload") + found_void_value = found_void_value || strings.contains(diagnostic.message, "void variant 'empty' takes no value") + found_bare = found_bare || strings.contains(diagnostic.message, "field 'count' requires a value") + found_range_enum = found_range_enum || strings.contains(diagnostic.message, "range patterns only apply to scalar") + found_incompatible = found_incompatible || strings.contains(diagnostic.message, "capture group with incompatible types") + } + testing.expect(t, found_void_capture) + testing.expect(t, found_void_value) + testing.expect(t, found_bare) + testing.expect(t, found_range_enum) + testing.expect(t, found_incompatible) +} + @(test) yield_misuse_is_diagnosed :: proc(t: ^testing.T) { // A value block that does not end in `yield`, and a `yield` nested inside an diff --git a/examples/programs/match/main.bro b/examples/programs/match/main.bro index aade0b2..656c9a5 100644 --- a/examples/programs/match/main.bro +++ b/examples/programs/match/main.bro @@ -4,7 +4,8 @@ Animal :: enum { bird } -# tagged union over an existing enum (variants are a subset of the enum's members) +# tagged union over an existing enum (variants are a subset of the enum's members); +# dog and bird share the same payload type (i32), so a multi-pattern arm may capture both. Data :: union(Animal) { dog i32 bird i32 @@ -16,6 +17,17 @@ Shape :: union(enum) { circle i32 # radius } +Point :: struct { + x i32 + y i32 +} + +# tagged union with a void-payload variant (`empty` carries no value) +Box :: union(enum) { + point Point + empty void +} + # Statement match with payload capture; every variant returns, so the function needs # no trailing return (the desugared if/else chain covers all paths). describe :: func(d Data) i32 { @@ -37,39 +49,67 @@ area :: func(s Shape) i32 { return result } +# Same-type multi-pattern capture: `.dog` and `.bird` are both i32, so one capture binds +# either payload (read once at the union's shared carrier offset). +payload_of :: func(d Data) i32 { + v :: match d { + .dog, .bird |n|: n + } + return v +} + main :: func() i32 { + acc i32 = 0 + dog Data = Data{ dog = 9 } bird Data = Data{ bird = 38 } - total i32 = describe(dog) + describe(bird) # 10 + 40 = 50 + acc = acc + describe(dog) + describe(bird) # 10 + 40 = 50 - # enum statement match, exhaustive without an `else` + # same-type multi-pattern capture + acc = acc + payload_of(dog) + payload_of(bird) # 9 + 38 = 47 + + # enum statement match, exhaustive, with a multi-pattern arm a Animal = .bird rank i32 = 0 match a { - .dog: rank = 1 - .cat: rank = 2 - .bird: rank = 3 + .dog, .cat: rank = 1 + .bird: rank = 3 } + acc = acc + rank # +3 - # enum value match + # enum value match with a multi-pattern arm legs :: match a { - .dog: 4 - .cat: 4 - .bird: 2 + .dog, .cat: 4 + .bird: 2 } + acc = acc + legs # +2 - # integer match with a mandatory `else` + # scalar match: a range arm, a multi-literal arm, and a mandatory else bucket i32 = 0 match rank { - 1: bucket = 100 - 3: bucket = 5 - else: bucket = 99 + 0..3: bucket = 1 # exclusive 0,1,2 — does not include 3 + 3, 4: bucket = 5 # rank is 3 + else: bucket = 99 } + acc = acc + bucket # +5 - sq Shape = Shape{ square = 4 } - ci Shape = Shape{ circle = 2 } - shapes i32 = area(sq) + area(ci) # 16 + 12 = 28 + # void-payload variant: bare-key construction + a no-capture arm + e Box = Box{ empty } + hit i32 = 0 + match e { + .point |pt|: hit = pt.x + .empty: hit = 7 + } + acc = acc + hit # +7 - # 50 + 3 + 2 + 5 + 28 = 88 - return total + rank + legs + bucket + shapes - 88 + # pointer capture mutates the subject's payload in place + b Box = Box{ point = Point{ x = 1, y = 2 } } + match b { + .point |@p|: p.x = 10 + .empty: hit = hit + } + acc = acc + b.point.x # +10 (mutated through the @mut Point) + + # 50 + 47 + 3 + 2 + 5 + 7 + 10 = 124 + return acc - 124 }