From 462632554c34e49bb29d2cd82e8388ba72d5b475 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 28 Jun 2026 23:44:01 +0200 Subject: [PATCH] match statements --- TODO.md | 249 +++++++++++++++++- compiler/ast/ast.odin | 6 + compiler/checker/checker.odin | 438 +++++++++++++++++++++++++++++++ compiler/hir/hir.odin | 1 + compiler/ir/ir.odin | 1 + compiler/lexer/lexer.odin | 1 + compiler/llvm/llvm.odin | 16 +- compiler/lower/lower.odin | 14 +- compiler/parser/parser.odin | 110 +++++++- compiler/token/token.odin | 1 + compiler_tests.odin | 151 +++++++++++ examples/programs/match/main.bro | 75 ++++++ 12 files changed, 1057 insertions(+), 6 deletions(-) create mode 100644 examples/programs/match/main.bro diff --git a/TODO.md b/TODO.md index 10364a0..ecb5e17 100644 --- a/TODO.md +++ b/TODO.md @@ -468,10 +468,43 @@ - deferred to milestone 22 (`match`): safe tag dispatch + payload capture (`match x { .bird |v| … }`), exhaustiveness checking, and any first-class tag-read accessor -22. match statements with tagged unions payload unwrapping +22. match statements with tagged unions payload unwrapping (implemented; see below) + - `match { * }` dispatches on a tagged union, a plain enum, or a scalar + value. Arms are `.variant [|cap|]: `, `: `, or `else: `; a body + is a braced block or a single brace-less statement. Works as a **statement** and (on a + declaration/assignment RHS) as a **value source** (`x :: match … { … }`), where a + single-expression arm yields implicitly and a `{ … }` arm ends in `yield` (or exits on + every path), reusing the value-if branch rule from 20.6 + - **tagged unions:** `.variant |cap|:` binds the payload (`cap := subject.variant`, the + unchecked field reinterpret from 21.5); `.variant:` ignores it. Exhaustive over the + union's *variants* (its fields), not the whole tag enum + - **enums:** `.member:` arms, exhaustive over the enum's members; no capture + - **scalars (int/float/bool/char):** arbitrary-expression patterns compared with `==`; an + `else` is mandatory (the domain can't be enumerated) + - exhaustiveness is a **compile error**: a non-exhaustive enum/union match with no `else` + lists the missing variants, and a redundant `else` on an already-exhaustive match is + rejected. Other diagnostics: unknown variant/member, duplicate arm, a non-`.variant` + pattern for an enum/union subject, a capture on a non-union/`else` arm, an untagged-union + subject, and arms after `else` + - **the only new runtime capability is reading the discriminant** (the 21.5-deferred tag + accessor): a new `hir.Union_Tag` expr + `ir.Union_Tag` op load the tag enum at the + union's offset 0 (the union address *is* the tag address — no GEP). Everything else is a + checker-only desugar to existing HIR (mirrors 18/19/20): the subject is spilled to one + temp, the tag read once into another, and the arms become an `if tag == .a { … } else if + … else { … }` chain (the last covered arm is promoted to the unconditional `else` so the + chain stays exhaustive and `all_paths_return` flows through). Value-match reuses the + value-if result-slot pattern (`new_value_slot`/`emit_value_branch`); captures reuse the + unwrap-if capture scoping. No new HIR/IR statement kinds; lowering/codegen add only the + one `Union_Tag` load + - parser/AST add a `match` keyword and `Match`/`Match_Arm` statement kinds (arms stored in + the `Match`'s `body`, pattern in `expr` with `INVALID` marking `else`, capture in + `captures`); a `union(enum)`/`union(Enum)` match resolves variant tags via the same + name→tag-enum-member lookup construction uses + - deferred (`// ponytail:` follow-ups): `void`-payload variants (`pending void`) — `void` + 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 -23. error types - - brolang should feature errors as values +23. error types (see below) 24. dynamic heap allocation - see below for direction @@ -851,6 +884,216 @@ message :: match code { } ``` +## A word on error handling + +Honey 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. + +### Fallible Functions + +Functions that can fail declare their error type after `!`: + +```honey +read_file :: func(path: []u8) []u8 ! IoError { ... } +``` + +This reads as: "returns `[]u8` or fails with `IoError`." The space around `!` is idiomatic but not required. + +### Error Types + +Errors can be enums (when you only need to identify what went wrong) or tagged unions (when errors need to carry additional context). + +**Simple errors (enum):** + +```honey +SimpleError :: enum { + OutOfMemory, + InvalidSize, + Timeout, +} +``` + +**Rich errors (tagged union):** + +```honey +IoError :: union(enum) { + NotFound: struct { path: []u8 }, + PermissionDenied: struct { path: []u8, operation: []u8 }, + Timeout: struct { after_ms: u64 }, + ConnectionReset: void, # no additional data needed +} +``` + +**Constrained tagged union:** + +When you have a predefined set of error kinds, you can constrain the union: + +```honey +IoErrorKind :: enum { + not_found, + permission_denied, + timeout, +} + +IoError :: union(IoErrorKind) { + not_found: struct { path: []u8 }, + permission_denied: struct { path: []u8 }, + timeout: struct { after_ms: u64 }, +} +``` + +### Error Composition + +Functions that can fail with multiple error types use `|` to compose them: + +```honey +process :: func(path: []u8) Ast ! IoError | ParseError { ... } +``` + +Parentheses are optional but can aid readability: + +```honey +process :: func(path: []u8) Ast ! (IoError | ParseError) { ... } +``` + +Composed error types can also be defined standalone: + +```honey +ProcessError :: IoError | ParseError | ValidationError +process :: func(path: []u8) Ast ! ProcessError { ... } +``` + +### Inline Error Types + +For simple one-off cases, you can inline the error type: + +```honey +# Inline enum +simple :: func() void ! enum{Failed, Timeout} { ... } + +# Inline tagged union (when payloads are needed) +complex :: func() Data ! union(enum) { + OutOfMemory: struct { requested: usize }, + InvalidInput: void, +} { ... } +``` + +For anything non-trivial, prefer named error types for clarity. + +### Returning Errors + +The `error` keyword is a control flow statement that exits the function via the error channel — symmetric to return for success values: + +```honey +parse_section :: func(p: @mut Parser) void ! ParseError { + start_line := p.line + p.advance() + + # ... parsing logic ... + + if p.pos >= p.input.len or p.input[p.pos] != ']' { + error .unclosed_section{.line = start_line} + } + + # ... continue on success ... +} +``` + +Since errors are just union values, you can also construct them separately: + +```honey +# Construct error value (it's just a union) +e: ParseError = .timeout{.ms = 500} + +# Return it via error channel later +error e +``` + +The symmetry: + +* `return x` — exit function with success value +* `error e` — exit function with error value + +### Propagation with `try` + +The `try` keyword unwraps a successful result or returns early with the error: + +```honey +process :: func(path: []u8) Ast ! IoError | ParseError { + data := try read_file(path) # returns early if IoError + ast := try parse(data) # returns early if ParseError + return ast +} +``` + +When the function's error type is a composition, `try` automatically widens narrower error types to match the return type. + +### Handling with `catch` + +The `catch` keyword handles errors and provides a value to continue with. + +**Provide a fallback value:** + +```honey +data := read_file(path) catch default_data +``` + +**Handle with a block using** `yield`: + +```honey +data := read_file(path) catch |e| { + log("read failed: {}", {e}) + yield empty_data +} +use(data) # continues with data = empty_data +``` + +The `yield` keyword provides a value from a block to the enclosing expression. Execution continues after the statement. For single expressions, yield is implicit (e.g., `catch default_data`). For blocks, explicit `yield` is required. See the Yield section under Control Flow for the full rule. + +**Return from the function instead:** + +```honey +data := read_file(path) catch |e| { + log("read failed: {}", {e}) + return # exits the enclosing function +} +use(data) # never reached if error occurred +``` + +Use `return` when the error is unrecoverable at this level. + +**Match on specific error variants:** + +```honey +data := read_file(path) catch |e| match e { + .NotFound |info|: { + print("file not found: {s}", {info.path}) + yield create_default(info.path) + }, + .Timeout |info|: { + print("timed out after {d}ms", {info.after_ms}) + yield retry(path) + }, + .PermissionDenied: panic("cannot recover from permission error"), + .ConnectionReset: retry(path), +} +``` + +### Summary + +| Syntax | Meaning | +| -- | -- | +| `T ! E` | Function returns `T` or fails with `E` | +| `T ! E1 | E2` | Function can fail with either error type | +| `T ! (E1 | E2)` | Same, with optional parentheses | +| `error e` | Exit function via error channel | +| `error .variant{...}` | Construct and return error in one step | +| `try expr` | Unwrap success or propagate error | +| `expr catch fallback` | Provide fallback value on error | +| `expr catch |e| { ... }` | Handle error with block | +| `yield value` | Provide value from innermost block | +| `yield :label value` | Provide value from labeled block | +| `return` / `return value` | Exit function from within catch block | + ## A word on memory allocation (NOTE THAT SYNTAX MAY NOT MATCH BROLANG EXACTLY AND SHOULD BE TAKEN WITH A GRAIN OF SALT - INSPIRATION ONLY) diff --git a/compiler/ast/ast.odin b/compiler/ast/ast.odin index 7a88574..d21e0fb 100644 --- a/compiler/ast/ast.odin +++ b/compiler/ast/ast.odin @@ -134,6 +134,8 @@ Stmt_Kind :: enum u8 { Block, Defer, Yield, + Match, + Match_Arm, } Assignment_Op :: enum u8 { @@ -175,6 +177,10 @@ Stmt :: struct { // `Block` statements (a bare `{ ... }` scope) use `body` as their statements. // `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. captures: []symbol.Id, guard: Expr_Id, body: []Stmt_Id, diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index d82398f..d129500 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -175,6 +175,16 @@ symbol_text :: proc(checker: ^Checker, id: symbol.Id) -> string { return symbol.resolve(checker.symbols, id) } +// type_label renders a type for a diagnostic, resolving a named type (enum/union/struct/ +// distinct) to its declared source name; primitives and unnamed types fall back to +// `types.name` (which prints `` for anonymous nodes). +type_label :: proc(checker: ^Checker, value: types.Type) -> string { + if node, ok := types.node(&checker.module.types, value); ok && node.name != 0 { + return symbol_text(checker, symbol.Id(node.name)) + } + return types.name(value) +} + Constant_Frame :: struct { expr: ast.Expr_Id, stage: u8, @@ -776,6 +786,11 @@ mark_block_imports_used :: proc(checker: ^Checker, statements: []ast.Stmt_Id, fi case .Defer: deferred := [1]ast.Stmt_Id{statement.update} 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`. + mark_expr_imports_used(checker, statement.expr, file) + mark_block_imports_used(checker, statement.body, file) case .Break, .Continue: case .Invalid: } @@ -4923,6 +4938,18 @@ build_block :: proc( ctx.loop_floor = saved_floor ctx.defer_depth -= 1 append(ctx.defers, entry) + case .Match: + build_match(ctx, &body, statement) + case .Match_Arm: + // Arms are only reachable through their enclosing `.Match`; one on its own + // is a parser bug. + append(&body, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Trap, span = statement.span, expr = hir.INVALID_EXPR, + local = hir.INVALID_LOCAL, + diagnostic = source.add(checker.diagnostics, statement.span, "unexpected match arm outside 'match'"), + }) + ctx.problematic^ = true case .Invalid: append(&body, hir.stmt_id(len(checker.module.statements))) append(&checker.module.statements, hir.Stmt{ @@ -5051,6 +5078,8 @@ build_value_source :: proc( return build_value_if(ctx, body, body_stmts[0], expected, span) case .For, .While: return build_value_loop(ctx, body, body_stmts[0], expected, span) + case .Match: + return build_value_match(ctx, body, body_stmts[0], expected, span) } } return build_value_block(ctx, body, body_stmts, expected, span) @@ -5309,6 +5338,415 @@ emit_value_branch :: proc( return true } +// Match_Built_Arm holds one already-built arm: its dispatch condition (`INVALID_EXPR` +// for the terminal `else`/exhaustive arm) and its body statements. The chain is +// assembled backward from these so diagnostics stay in source order. +Match_Built_Arm :: struct { + condition: hir.Expr_Id, + body: []hir.Stmt_Id, + terminal: bool, +} + +// 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 +// bodies are ordinary statement blocks. Returns false (and emits a `.Trap`) on any error. +emit_match :: proc( + ctx: ^Build_Ctx, + out: ^[dynamic]hir.Stmt_Id, + statement: ast.Stmt, + as_value: bool, + slot: ^hir.Local_Id, + slot_type: ^types.Type, +) -> bool { + checker := ctx.checker + store := &checker.module.types + span := statement.span + + fail :: proc(ctx: ^Build_Ctx, out: ^[dynamic]hir.Stmt_Id, span: source.Span, diagnostic: source.Diagnostic_Id) -> bool { + checker := ctx.checker + append(out, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Trap, span = span, expr = hir.INVALID_EXPR, + local = hir.INVALID_LOCAL, diagnostic = diagnostic, + }) + ctx.problematic^ = true + return false + } + + // 1. Subject, spilled into an addressable temp so the tag read and any payload + // captures reference one evaluation. + 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 { + return fail(ctx, out, span, checker.module.exprs[subject].diagnostic) + } + + is_tagged := types.is_tagged_union(subject_type, store) + is_enum_subject := types.is_enum(subject_type, store) + if types.is_union(subject_type, store) && !is_tagged { + return fail(ctx, out, span, source.add(checker.diagnostics, span, "cannot 'match' on an untagged union; it has no tag to dispatch on")) + } + if !is_tagged && !is_enum_subject && !types.is_concrete_scalar(subject_type) { + return fail(ctx, out, span, source.addf(checker.diagnostics, span, + "'match' subject must be a tagged union, enum, or scalar value, not '%s'", type_label(checker, subject_type))) + } + + 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(out, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Declaration, span = span, local = subj_local, expr = subject, + 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 + 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), + target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, + }) + tag_local := hir.local_id(len(ctx.hir_locals^)) + append(ctx.hir_locals, hir.Local{name = checker.sink_symbol, type = tag_enum, mutable = false}) + append(out, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Declaration, span = span, local = tag_local, expr = tag_read, + diagnostic = source.INVALID_DIAGNOSTIC, + }) + key_local = tag_local + key_type = tag_enum + } + + // 3. Build each arm (forward, for source-order diagnostics). + built: [dynamic]Match_Built_Arm + built.allocator = checker.allocator + defer delete(built) + covered: [dynamic]symbol.Id + 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] + if arm.kind != .Match_Arm { + ok = false + continue + } + if has_else { + source.add(checker.diagnostics, arm.span, "arms after 'else' are unreachable") + ok = false + } + is_else := arm.expr == ast.INVALID_EXPR + condition := hir.INVALID_EXPR + field_index := -1 + payload_type := types.INVALID + has_capture := len(arm.captures) > 0 + + if is_else { + if has_capture { + source.add(checker.diagnostics, arm.span, "the 'else' arm cannot capture a payload") + ok = false + } + 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)) + 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{ + 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 { + 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)) + ok = false + continue + } + 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 { + 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 + } + 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) + if !body_ok { + ok = false + } + append(&built, Match_Built_Arm{condition = condition, body = arm_body, terminal = is_else}) + } + + // 4. Exhaustiveness. Enum/union matches must cover every variant or supply `else`; + // an already-exhaustive match must not carry a redundant `else`. The last + // covered arm is promoted to the unconditional `else` so the chain terminates. + if is_tagged || is_enum_subject { + all_names := types.enum_members_for(store, tag_enum) if is_tagged else types.enum_members_for(store, subject_type) + names: [dynamic]symbol.Id + names.allocator = checker.allocator + defer delete(names) + if is_tagged { + for field in types.fields_for(store, subject_type) { + append(&names, symbol.Id(field.name)) + } + } else { + for member in all_names { + append(&names, symbol.Id(member.name)) + } + } + missing: [dynamic]symbol.Id + missing.allocator = checker.allocator + defer delete(missing) + for name in names { + if !contains_name(covered[:], name) { + append(&missing, name) + } + } + if has_else { + if len(missing) == 0 { + source.add(checker.diagnostics, span, "redundant 'else': the 'match' already covers every variant") + ok = false + } + } else if len(missing) > 0 { + builder: strings.Builder + strings.builder_init(&builder, checker.allocator) + defer strings.builder_destroy(&builder) + for name, index in missing { + if index > 0 { + strings.write_string(&builder, ", ") + } + strings.write_string(&builder, ".") + strings.write_string(&builder, symbol_text(checker, name)) + } + source.addf(checker.diagnostics, span, "'match' on '%s' is not exhaustive; missing variants: %s (add the arms or an 'else')", + type_label(checker, subject_type), strings.to_string(builder)) + ok = false + } else if len(built) > 0 { + built[len(built) - 1].terminal = true + } + } else if !has_else { + source.addf(checker.diagnostics, span, "a 'match' on '%s' requires an 'else' arm", type_label(checker, subject_type)) + ok = false + } + + if !ok { + // The arm bodies never get wired into the (un-assembled) chain, so free them here. + for arm in built { + delete(arm.body, checker.allocator) + } + return fail(ctx, out, span, source.INVALID_DIAGNOSTIC) + } + + // 5. Assemble the if/else chain backward from the built arms. The terminal arm is the + // final (else / promoted) one; the rest nest as `if cond { body } else { … }`. + else_chain: []hir.Stmt_Id = nil + start := len(built) + if len(built) > 0 && built[len(built) - 1].terminal { + else_chain = built[len(built) - 1].body + start = len(built) - 1 + } + for i := start - 1; i >= 0; i -= 1 { + arm := built[i] + wrapper := make([]hir.Stmt_Id, 1, checker.allocator) + wrapper[0] = hir.stmt_id(len(checker.module.statements)) + append(&checker.module.statements, hir.Stmt{ + kind = .If, span = span, expr = arm.condition, guard = hir.INVALID_EXPR, + then_body = arm.body, else_body = else_chain, + local = hir.INVALID_LOCAL, target = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, + }) + else_chain = wrapper + } + for s in else_chain { + append(out, s) + } + // The outermost chain slice's ids are now copied into `out`; the inner slices are + // owned by their enclosing `.If` (freed with the HIR module). + delete(else_chain, checker.allocator) + return true +} + +// build_match_arm_body builds one arm's body, prefixed with the optional payload capture +// (`cap := subject.variant`, an unchecked reinterpret like a Zig union field read). For a +// statement match it is a plain block; for a value match each path assigns the result slot +// (a single-expression arm yields implicitly). +build_match_arm_body :: proc( + ctx: ^Build_Ctx, + arm: ast.Stmt, + subject_type: types.Type, + subj_local: hir.Local_Id, + field_index: int, + payload_type: types.Type, + as_value: bool, + slot: ^hir.Local_Id, + slot_type: ^types.Type, + span: source.Span, +) -> ([]hir.Stmt_Id, bool) { + checker := ctx.checker + result: [dynamic]hir.Stmt_Id + result.allocator = checker.allocator + capture_start := len(ctx.locals^) + + 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}) + 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), + target = hir.INVALID_REF, right = hir.INVALID_EXPR, diagnostic = source.INVALID_DIAGNOSTIC, + }) + 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, + diagnostic = source.INVALID_DIAGNOSTIC, + }) + } + } + + body_ok := true + if !as_value { + built := build_block(ctx, arm.body) + for s in built { + append(&result, s) + } + delete(built, checker.allocator) + } else { + body_ok = build_value_arm(ctx, &result, arm.body, slot, slot_type, span) + } + resize(ctx.locals, capture_start) + return result[:], body_ok +} + +// build_value_arm appends a value-match arm's slot assignment(s) to `out`: a single bare +// expression yields implicitly; anything else reuses the value-branch rule (trailing +// `yield`, or exit on every path). +build_value_arm :: proc( + ctx: ^Build_Ctx, + out: ^[dynamic]hir.Stmt_Id, + arm_body: []ast.Stmt_Id, + slot: ^hir.Local_Id, + slot_type: ^types.Type, + span: source.Span, +) -> bool { + checker := ctx.checker + if len(arm_body) == 1 && checker.ast_module.statements[arm_body[0]].kind == .Expression { + expr_stmt := checker.ast_module.statements[arm_body[0]] + expected := slot_type^ if slot^ != hir.INVALID_LOCAL else types.INVALID + value := build_expr(checker, expr_stmt.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, expected, ctx.pkg, ctx.file) + if checker.module.exprs[value].kind == .Invalid { + ctx.problematic^ = true + return false + } + vtype := checker.module.exprs[value].type + if slot^ == hir.INVALID_LOCAL { + slot_type^ = vtype + slot^ = new_value_slot(ctx, slot_type^) + } else { + value = coerce_expr(checker, value, slot_type^, span) + } + emit_slot_assign(checker, out, slot^, value, span) + return true + } + return emit_value_branch(ctx, out, arm_body, slot, slot_type, span) +} + +// build_match desugars a statement-position `match` into its if/else chain. +build_match :: proc(ctx: ^Build_Ctx, body: ^[dynamic]hir.Stmt_Id, statement: ast.Stmt) { + slot := hir.INVALID_LOCAL + slot_type := types.INVALID + emit_match(ctx, body, statement, false, &slot, &slot_type) +} + +// build_value_match desugars a `match` used as a declaration/assignment RHS: a result slot +// each arm assigns, read after the chain. Mirrors build_value_if. +build_value_match :: proc( + ctx: ^Build_Ctx, + body: ^[dynamic]hir.Stmt_Id, + match_id: ast.Stmt_Id, + expected: types.Type, + span: source.Span, +) -> (value: hir.Expr_Id, value_type: types.Type) { + checker := ctx.checker + statement := checker.ast_module.statements[match_id] + slot := hir.INVALID_LOCAL + slot_type := types.INVALID + if is_runtime_type(checker, expected) { + slot_type = expected + slot = new_value_slot(ctx, slot_type) + } + subtree: [dynamic]hir.Stmt_Id + subtree.allocator = checker.allocator + ok := emit_match(ctx, &subtree, statement, true, &slot, &slot_type) + if !ok || slot == hir.INVALID_LOCAL { + for s in subtree { + append(body, s) + } + delete(subtree) + ctx.problematic^ = true + return invalid_hir_expr(checker, span, source.INVALID_DIAGNOSTIC), types.INVALID + } + append(body, hir.stmt_id(len(checker.module.statements))) + append(&checker.module.statements, hir.Stmt{ + kind = .Declaration, span = span, local = slot, expr = hir.INVALID_EXPR, + diagnostic = source.INVALID_DIAGNOSTIC, + }) + for s in subtree { + append(body, s) + } + delete(subtree) + return slot_read(checker, slot, slot_type, span), slot_type +} + // loop_yields_none reports whether any `yield` that targets this loop (a labeled // `yield :blk` inside `if`/block branches, or the trailing fall-through) yields the // literal `none` — making the loop's result optional. Pure AST walk; does not descend diff --git a/compiler/hir/hir.odin b/compiler/hir/hir.odin index 67d9f17..2d5e988 100644 --- a/compiler/hir/hir.odin +++ b/compiler/hir/hir.odin @@ -90,6 +90,7 @@ Expr_Kind :: enum u8 { Index, Slice, Field, + Union_Tag, Length, Slice_Ptr, Unwrap, diff --git a/compiler/ir/ir.odin b/compiler/ir/ir.odin index 791db24..aee872e 100644 --- a/compiler/ir/ir.odin +++ b/compiler/ir/ir.odin @@ -79,6 +79,7 @@ Opcode :: enum u8 { Alloca, Index_Address, Field_Address, + Union_Tag, Load, Store, Fill, diff --git a/compiler/lexer/lexer.odin b/compiler/lexer/lexer.odin index 4594236..bbe1aa3 100644 --- a/compiler/lexer/lexer.odin +++ b/compiler/lexer/lexer.odin @@ -37,6 +37,7 @@ keyword_kind :: proc(text: string) -> token.Kind { case "continue": return .Keyword_Continue case "defer": return .Keyword_Defer case "yield": return .Keyword_Yield + case "match": return .Keyword_Match case "else": return .Keyword_Else case "true": return .Keyword_True case "false": return .Keyword_False diff --git a/compiler/llvm/llvm.odin b/compiler/llvm/llvm.odin index 3fcf178..29bf367 100644 --- a/compiler/llvm/llvm.odin +++ b/compiler/llvm/llvm.odin @@ -253,7 +253,7 @@ valid_value :: proc( } switch instructions[value_id].op { case .Param, .Const, .String, .Aggregate, .None, .Optional_Some, - .Load_Global, .Function_Address, .Address_Of, .Load, .Slice, .Length, .Slice_Ptr, + .Load_Global, .Function_Address, .Address_Of, .Load, .Union_Tag, .Slice, .Length, .Slice_Ptr, .Extract, .Select, .Unwrap, .Optional_Is_Some, .Optional_Value, .Orelse, .Widen, .C_Coerce, .C_Vararg_Promote, .Retype, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer, @@ -979,6 +979,20 @@ emit_instruction_stream :: proc( llvm_type(instruction.type, &emitter.module.types), instruction.a, ) + case .Union_Tag: + // `instruction.a` is the address of a tagged union; the discriminant lives at + // offset 0, so load the tag enum (`instruction.type`) straight from it. + if !valid_instruction(instructions, instruction.a) { + emit_recovery_value(emitter, instruction_index, instruction, "invalid union tag base") + continue + } + fmt.sbprintf( + &emitter.builder, + " %%v%d = load %s, ptr %%v%d\n", + instruction_index, + llvm_type(instruction.type, &emitter.module.types), + instruction.a, + ) case .Store: if !valid_address(instructions, instruction.a, instruction.type, &emitter.module.types) || !valid_value(instructions, instruction.b, instruction.type, &emitter.module.types) { diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index de44f5c..3170a6b 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -275,6 +275,18 @@ lower_compound_expr :: proc(state: ^State, expr_id: hir.Expr_Id) -> ir.Instructi target=ir.INVALID_REF, a=location, b=ir.INVALID_INSTRUCTION, diagnostic=source.INVALID_DIAGNOSTIC, }) + case .Union_Tag: + // Read a tagged union's discriminant: the tag sits at offset 0, so the union's + // address is the tag's address — load the tag enum (`expr.type`) directly. + address := lower_location(state, expr.left) + if address == ir.INVALID_INSTRUCTION { + return append_recovery_value(state, expr.span, expr.type, expr.diagnostic) + } + return append_instruction(state, ir.Instruction{ + op=.Union_Tag, span=expr.span, type=expr.type, + target=ir.INVALID_REF, a=address, b=ir.INVALID_INSTRUCTION, + diagnostic=source.INVALID_DIAGNOSTIC, + }) case .Slice: // Array operands are addressed (locations) or spilled to a temporary // (rvalues) by lower_location; other containers are slice/pointer values. @@ -460,7 +472,7 @@ lower_expr :: proc(state: ^State, expr_id: hir.Expr_Id) -> ir.Instruction_Id { }) _ = pop(&stack) case .String, .Array, .Struct, .Range, .None, .Optional_Some, .Address, .Deref, - .Index, .Slice, .Field, .Length, .Slice_Ptr, .Unwrap, .Orelse, + .Index, .Slice, .Field, .Union_Tag, .Length, .Slice_Ptr, .Unwrap, .Orelse, .Not, .Eq, .Ne, .Lt, .Le, .Gt, .Ge, .And, .Or: last = lower_compound_expr(state, frame.expr) _ = pop(&stack) diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index bc41f99..6f77f45 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -1128,6 +1128,7 @@ parse_value_control_flow :: proc(parser: ^Parser) -> (ast.Stmt_Id, bool) { case .Keyword_If: return parse_if(parser), true case .Keyword_For: return parse_for(parser), true case .Keyword_While: return parse_while(parser), true + case .Keyword_Match: return parse_match(parser), true } return ast.INVALID_STMT, false } @@ -1157,6 +1158,9 @@ parse_statement :: proc(parser: ^Parser) -> ast.Stmt_Id { if current(parser).kind == .Keyword_Yield { return parse_yield(parser) } + if current(parser).kind == .Keyword_Match { + return parse_match(parser) + } // A leading `{` opens a bare block scope (struct literals are postfix only). if current(parser).kind == .Left_Brace { return parse_block_statement(parser) @@ -1526,6 +1530,110 @@ parse_if :: proc(parser: ^Parser) -> ast.Stmt_Id { return id } +// parse_arm_body parses a match arm's body after the `:`: a braced block (whose +// inner statements are returned unwrapped, like `parse_branch_body`) or a single +// brace-less statement. For value-match a brace-less body is a single expression +// that the checker yields implicitly. +parse_arm_body :: proc(parser: ^Parser) -> []ast.Stmt_Id { + skip_newlines(parser) + if current(parser).kind == .Left_Brace { + return parse_block(parser) + } + single := make([]ast.Stmt_Id, 1, parser.module.allocator) + single[0] = parse_statement(parser) + 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 :: proc(parser: ^Parser) -> ast.Stmt_Id { + start := current(parser).span + pattern := ast.INVALID_EXPR + captures: [dynamic]symbol.Id + captures.allocator = parser.module.allocator + if _, is_else := allow(parser, .Keyword_Else); !is_else { + saved := parser.no_struct_literal + parser.no_struct_literal = true + pattern = parse_expression(parser) + parser.no_struct_literal = saved + if _, ok := allow(parser, .Pipe); ok { + 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 a capture name after '|'") + } + if _, close_ok := allow(parser, .Pipe); !close_ok { + source.add(parser.diagnostics, current(parser).span, "expected '|' to close the match capture") + } + } + } + if _, ok := allow(parser, .Colon); !ok { + source.add(parser.diagnostics, current(parser).span, "expected ':' after a match pattern") + } + body := parse_arm_body(parser) + id := ast.stmt_id(len(parser.module.statements)) + append(&parser.module.statements, ast.Stmt{ + kind=.Match_Arm, + span=span_from(start, previous(parser).span), + expr=pattern, + captures=captures[:], + body=body, + target=ast.INVALID_EXPR, + update=ast.INVALID_STMT, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + return id +} + +// parse_match parses `match { * }`. Arms are newline-separated; each +// is a `Match_Arm` statement stored in the `Match`'s `body`. Usable as a statement +// and (via `parse_value_control_flow`) as a value source on a declaration/assignment. +parse_match :: proc(parser: ^Parser) -> ast.Stmt_Id { + start := advance(parser) // consume 'match' + skip_newlines(parser) + saved := parser.no_struct_literal + parser.no_struct_literal = true + subject := parse_expression(parser) + parser.no_struct_literal = saved + arms: [dynamic]ast.Stmt_Id + arms.allocator = parser.module.allocator + skip_newlines(parser) + if _, ok := allow(parser, .Left_Brace); !ok { + source.add(parser.diagnostics, current(parser).span, "expected '{' to open match arms") + } + skip_newlines(parser) + for current(parser).kind != .Right_Brace && current(parser).kind != .Eof { + append(&arms, parse_match_arm(parser)) + if diagnostic := finish_statement(parser, true); diagnostic != source.INVALID_DIAGNOSTIC { + arm_id := ast.stmt_id(len(parser.module.statements)) + append(&parser.module.statements, ast.Stmt{ + kind=.Invalid, + span=current(parser).span, + expr=ast.INVALID_EXPR, + diagnostic=diagnostic, + }) + append(&arms, arm_id) + } + } + if _, ok := allow(parser, .Right_Brace); !ok { + source.add(parser.diagnostics, current(parser).span, "expected '}' to close match arms") + } + id := ast.stmt_id(len(parser.module.statements)) + append(&parser.module.statements, ast.Stmt{ + kind=.Match, + span=span_from(start.span, previous(parser).span), + expr=subject, + body=arms[:], + target=ast.INVALID_EXPR, + update=ast.INVALID_STMT, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + return id +} + parse_while_update :: proc(parser: ^Parser) -> ast.Stmt_Id { parenthesized := false if _, ok := allow(parser, .Left_Paren); ok { @@ -1559,7 +1667,7 @@ parse_while_update :: proc(parser: ^Parser) -> ast.Stmt_Id { statement := &parser.module.statements[update] switch statement.kind { case .Assignment, .Expression: - case .Invalid, .Declaration, .Return, .If, .While, .For, .Break, .Continue, .Block, .Defer, .Yield: + case .Invalid, .Declaration, .Return, .If, .While, .For, .Break, .Continue, .Block, .Defer, .Yield, .Match, .Match_Arm: diagnostic := source.add( parser.diagnostics, statement.span, diff --git a/compiler/token/token.odin b/compiler/token/token.odin index 58ba846..482a895 100644 --- a/compiler/token/token.odin +++ b/compiler/token/token.odin @@ -72,6 +72,7 @@ Kind :: enum u8 { Keyword_Continue, Keyword_Defer, Keyword_Yield, + Keyword_Match, Keyword_Else, Keyword_True, Keyword_False, diff --git a/compiler_tests.odin b/compiler_tests.odin index 4831c92..39c7733 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2251,6 +2251,157 @@ main :: func() i32 { testing.expect(t, found_variant) } +@(test) +match_compiles_and_runs :: proc(t: ^testing.T) { + output := "/tmp/brolang-test-match" + defer _ = os.remove(output) + status := compiler_core.compile_package("examples/programs/match", output) + testing.expect_value(t, status, 0) + state := run_executable(output) + // Exercises every match form — tagged-union statement match with payload capture, + // tagged-union value match (implicit + explicit yield), enum statement/value match, + // and integer match with `else` — all summed to a self-checking 0 on success. + testing.expect_value(t, state.exit_code, 0) +} + +@(test) +match_dispatches_on_the_tag :: proc(t: ^testing.T) { + // A tagged-union match desugars to: read the discriminant once (a load of the tag + // enum at the union's offset 0), then compare it against each variant's tag value. + // `Animal` is dense in a u8 backing (dog=0, bird=2), so the dog arm compares the + // loaded i8 tag against 0. + text := `Animal :: enum { + dog + cat + bird +} +Data :: union(Animal) { + dog i32 + bird i32 +} +main :: func() i32 { + d Data = Data{ bird = 7 } + out i32 = 0 + match d { + .dog |v|: out = v + .bird |v|: out = v + 1 + } + 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) + // The tag is loaded as an i8 and compared (icmp eq) against the dog variant's tag (0). + testing.expect(t, strings.contains(llvm_text, "load i8")) + testing.expect(t, strings.contains(llvm_text, "icmp eq i8")) +} + +@(test) +match_misuse_is_diagnosed :: proc(t: ^testing.T) { + // Five rejected matches: (1) a non-exhaustive enum match with no `else`, (2) a scalar + // match missing its mandatory `else`, (3) a payload capture on a non-union arm, (4) a + // redundant `else` on an already-exhaustive match, and (5) an unknown variant. + text := `Animal :: enum { + dog + cat + bird +} +Data :: union(Animal) { + dog i32 + bird i32 +} +not_exhaustive :: func(a Animal) i32 { + match a { + .dog: { return 1 } + .cat: { return 2 } + } + return 0 +} +missing_else :: func(n i32) i32 { + match n { + 0: { return 1 } + 1: { return 2 } + } + return 0 +} +bad_capture :: func(a Animal) i32 { + match a { + .dog |v|: { return 1 } + .cat: { return 2 } + .bird: { return 3 } + } + return 0 +} +redundant_else :: func(a Animal) i32 { + match a { + .dog: { return 1 } + .cat: { return 2 } + .bird: { return 3 } + else: { return 4 } + } + return 0 +} +unknown_variant :: func(d Data) i32 { + match d { + .dog: { return 1 } + .snake: { return 2 } + else: { return 3 } + } + return 0 +} +main :: func() i32 { + # Functions are specialized on use, so call each so its body is type-checked. + d Data = Data{ dog = 0 } + return not_exhaustive(.dog) + missing_else(0) + bad_capture(.dog) + + redundant_else(.dog) + unknown_variant(d) +} +` + 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_exhaustive := false + found_missing_else := false + found_capture := false + found_redundant := false + found_unknown := false + for diagnostic in diagnostics.items { + found_exhaustive = found_exhaustive || strings.contains(diagnostic.message, "is not exhaustive") + found_missing_else = found_missing_else || strings.contains(diagnostic.message, "requires an 'else' arm") + found_capture = found_capture || strings.contains(diagnostic.message, "only tagged-union variants can capture") + found_redundant = found_redundant || strings.contains(diagnostic.message, "redundant 'else'") + found_unknown = found_unknown || strings.contains(diagnostic.message, "unknown variant '.snake'") + } + testing.expect(t, found_exhaustive) + testing.expect(t, found_missing_else) + testing.expect(t, found_capture) + testing.expect(t, found_redundant) + testing.expect(t, found_unknown) +} + @(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 new file mode 100644 index 0000000..aade0b2 --- /dev/null +++ b/examples/programs/match/main.bro @@ -0,0 +1,75 @@ +Animal :: enum { + dog + cat + bird +} + +# tagged union over an existing enum (variants are a subset of the enum's members) +Data :: union(Animal) { + dog i32 + bird i32 +} + +# tagged union with a synthesized tag enum +Shape :: union(enum) { + square i32 # side + circle i32 # radius +} + +# 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 { + match d { + .dog |age|: return age + 1 + .bird |wingspan|: return wingspan + 2 + } +} + +# Value match: single-expression arms yield implicitly, a block arm yields explicitly. +area :: func(s Shape) i32 { + result :: match s { + .square |side|: side * side + .circle |r|: { + # 3 ~ pi, integer arithmetic + yield 3 * r * r + } + } + return result +} + +main :: func() i32 { + dog Data = Data{ dog = 9 } + bird Data = Data{ bird = 38 } + total i32 = describe(dog) + describe(bird) # 10 + 40 = 50 + + # enum statement match, exhaustive without an `else` + a Animal = .bird + rank i32 = 0 + match a { + .dog: rank = 1 + .cat: rank = 2 + .bird: rank = 3 + } + + # enum value match + legs :: match a { + .dog: 4 + .cat: 4 + .bird: 2 + } + + # integer match with a mandatory `else` + bucket i32 = 0 + match rank { + 1: bucket = 100 + 3: bucket = 5 + else: bucket = 99 + } + + sq Shape = Shape{ square = 4 } + ci Shape = Shape{ circle = 2 } + shapes i32 = area(sq) + area(ci) # 16 + 12 = 28 + + # 50 + 3 + 2 + 5 + 28 = 88 + return total + rank + legs + bucket + shapes - 88 +}