From 5a958d9bfd0c50911f693de60276b8c73fd53267 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 13 Jul 2026 17:51:59 +0200 Subject: [PATCH] allow keywords as values in enums (and tagged unions) --- LANGUAGE.md | 28 ++++++++++++ compiler/lexer/lexer.odin | 1 + compiler/parser/parser.odin | 51 ++++++++++++++-------- compiler/token/token.odin | 7 ++- compiler_tests.odin | 87 +++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 18 deletions(-) diff --git a/LANGUAGE.md b/LANGUAGE.md index 1e3ac75..d85c8de 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -39,6 +39,34 @@ roadmap and milestone history. - native sum composition with `A | B` for unbacked enums and tagged unions, using program-global `u16` variant ids - fallible channel types `T ! E`, where `E` is a native enum/tagged union or supported sum composition +#### keyword member names + +Reserved keywords are valid native enum members and tagged-union variants when used in an +unambiguous member context: + +```bro +TokenKind :: enum { + if + else + return +} + +Token :: union(TokenKind) { + if i32 + else void + return i32 +} + +conditional func() TokenKind { return TokenKind.if } +fallback func() TokenKind { return .else } +token func() Token { return Token{ if = 1 } } +``` + +Keyword variants also work with field access and `.variant` match patterns; `.else:` remains +distinct from the `else:` catch-all arm. No escaping syntax is required. Keywords remain reserved +for ordinary declarations, struct fields, untagged-union fields, and anonymous payload-struct +fields. `_` is not a keyword member name. + ### expressions and control flow - checked integer `+ - *`, unary `-`, float-only `/`, IEEE float arithmetic, comparisons, `!`, `and`, and `or` diff --git a/compiler/lexer/lexer.odin b/compiler/lexer/lexer.odin index e3bab10..62bc6fe 100644 --- a/compiler/lexer/lexer.odin +++ b/compiler/lexer/lexer.odin @@ -105,6 +105,7 @@ lex :: proc( ) -> token.Stream { stream: token.Stream stream.items.allocator = allocator + stream.symbols = symbols bytes := transmute([]byte)source_file.text cursor := 0 diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index de2304d..02a49df 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -86,6 +86,18 @@ allow :: proc(parser: ^Parser, kind: token.Kind) -> (token.Token, bool) { return current(parser), false } +parse_member_name :: proc(parser: ^Parser, allow_keyword := true) -> (token.Token, bool) { + name := current(parser) + if name.kind != .Identifier && (!allow_keyword || !token.is_keyword(name.kind)) { + return name, false + } + advance(parser) + if name.kind != .Identifier { + name.symbol = symbol.intern(parser.tokens.symbols, token_text(parser, name)) + } + return name, true +} + skip_newlines :: proc(parser: ^Parser) { for current(parser).kind == .Newline { advance(parser) @@ -539,12 +551,11 @@ parse_keyed_initializers :: proc( args.allocator = parser.module.allocator skip_newlines(parser) for current(parser).kind != .Right_Brace && current(parser).kind != .Eof { - field := current(parser) - if field.kind != .Identifier { + field, field_ok := parse_member_name(parser) + if !field_ok { source.add(parser.diagnostics, field.span, "expected a keyed struct field initializer") break } - advance(parser) // 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 @@ -804,11 +815,10 @@ parse_primary :: proc(parser: ^Parser, nesting: int) -> ast.Expr_Id { return parse_array_literal(parser, nesting) case .Dot: start := advance(parser) - member := current(parser) - if member.kind != .Identifier { + member, member_ok := parse_member_name(parser) + if !member_ok { return invalid_expr(parser, member.span, "expected an enum member after '.'") } - advance(parser) payload := ast.INVALID_EXPR end := member.span if left_brace, ok := allow(parser, .Left_Brace); ok { @@ -868,11 +878,12 @@ parse_primary :: proc(parser: ^Parser, nesting: int) -> ast.Expr_Id { name := first qualifier := symbol.INVALID if _, ok := allow(parser, .Dot); ok { - if current(parser).kind != .Identifier { + member, member_ok := parse_member_name(parser) + if !member_ok { return invalid_expr(parser, current(parser).span, "expected a package member after '.'") } qualifier = first.symbol - name = advance(parser) + name = member } if current(parser).kind == .Left_Paren { call := parse_call(parser, qualifier, first, name, nesting) @@ -1074,12 +1085,11 @@ parse_expression_bp :: proc(parser: ^Parser, minimum_binding_power, nesting: int } if current(parser).kind == .Dot { advance(parser) - field := current(parser) - if field.kind != .Identifier { + field, field_ok := parse_member_name(parser) + if !field_ok { left = invalid_expr(parser, field.span, "expected a field name after '.'") continue } - advance(parser) left_expr := parser.module.exprs[left] left = add_expr(parser, ast.Expr{ kind=.Field, @@ -2252,6 +2262,7 @@ parse_record_body :: proc( fields: ^[dynamic]types.Field, expected_open: string, allow_anonymous_struct_payload := false, + allow_keyword_names := false, ) -> bool { if _, ok := allow(parser, .Left_Brace); !ok { source.add(parser.diagnostics, current(parser).span, expected_open) @@ -2259,7 +2270,8 @@ parse_record_body :: proc( } skip_newlines(parser) for current(parser).kind != .Right_Brace && current(parser).kind != .Eof { - if current(parser).kind != .Identifier { + field_name, field_ok := parse_member_name(parser, allow_keyword_names) + if !field_ok { source.add(parser.diagnostics, current(parser).span, "expected a struct field name") for current(parser).kind != .Newline && current(parser).kind != .Right_Brace && @@ -2269,7 +2281,6 @@ parse_record_body :: proc( skip_newlines(parser) continue } - field_name := advance(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 { @@ -2317,7 +2328,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", true) || !valid { + if !parse_record_body(parser, &fields, "expected '{' after inline union error type", true, true) || !valid { return types.INVALID } tag := synthesize_union_tag(parser, fields[:]) @@ -2372,7 +2383,13 @@ parse_struct :: proc(parser: ^Parser, name: token.Token, c_layout: bool, is_unio fields.allocator = parser.module.allocator defer delete(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) + _ = parse_record_body( + parser, + &fields, + "expected '{' after struct fields", + allow_anonymous_struct_payload, + allow_anonymous_struct_payload, + ) if is_union && (inferred_tag || types.is_valid(declared_tag)) { tag = synthesize_union_tag(parser, fields[:]) } @@ -2479,7 +2496,8 @@ parse_enum_body :: proc( has_previous := false skip_newlines(parser) for current(parser).kind != .Right_Brace && current(parser).kind != .Eof { - if current(parser).kind != .Identifier { + member, member_ok := parse_member_name(parser) + if !member_ok { source.add(parser.diagnostics, current(parser).span, "expected an enum member name") for current(parser).kind != .Newline && current(parser).kind != .Right_Brace && @@ -2489,7 +2507,6 @@ parse_enum_body :: proc( skip_newlines(parser) continue } - member := advance(parser) duplicate := false for existing in members^ { if existing.name == u32(member.symbol) { diff --git a/compiler/token/token.odin b/compiler/token/token.odin index d0d172d..4d8cde4 100644 --- a/compiler/token/token.odin +++ b/compiler/token/token.odin @@ -114,6 +114,10 @@ Kind :: enum u8 { Keyword_C_Longdouble, } +is_keyword :: proc(kind: Kind) -> bool { + return kind >= .Keyword_Func && kind <= .Keyword_C_Longdouble +} + Token :: struct { span: source.Span, symbol: symbol.Id, @@ -122,5 +126,6 @@ Token :: struct { } Stream :: struct { - items: [dynamic]Token, + items: [dynamic]Token, + symbols: ^symbol.Table, } diff --git a/compiler_tests.odin b/compiler_tests.odin index 3598905..8a6e349 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -10223,6 +10223,93 @@ main func() i32 { testing.expect(t, found_promotion) } +@(test) +keywords_are_valid_enum_members_and_tagged_union_variants :: proc(t: ^testing.T) { + testing.expect(t, token.is_keyword(.Keyword_Func)) + testing.expect(t, token.is_keyword(.Keyword_C_Longdouble)) + testing.expect(t, !token.is_keyword(.Identifier)) + testing.expect(t, !token.is_keyword(.Underscore)) + + text := `TokenKind :: enum { + if + else + return +} +Token :: union(TokenKind) { + if i32 + else void + return i32 +} +kind func(value bool) TokenKind { + if value { + return .if + } + return TokenKind.else +} +main func() i32 { + first TokenKind = kind(true) + second TokenKind = .return + a Token = Token{ if = 1 } + b Token = Token{ else } + c Token = .return{2} + total i32 = a.if + c.return + match first { + .if: total = total + 1 + .else: total = total + 2 + .return: total = total + 3 + } + match b { + .if |value|: total = total + value + .else: total = total + 4 + .return |value|: total = total + value + } + _ = second + return total +} +` + 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, len(llvm_text) > 0) +} + +@(test) +keyword_names_remain_invalid_for_struct_fields :: proc(t: ^testing.T) { + text := `Bad :: struct { + if i32 +} +` + source_file := source.Source{path="test.bro", text=text} + diagnostics := source.init_diagnostics(&source_file) + defer source.destroy_diagnostics(&diagnostics) + symbols := symbol.init_table() + defer symbol.destroy_table(&symbols) + stream := lexer.lex(&source_file, &diagnostics, &symbols) + defer delete(stream.items) + module := parser.parse(&stream, &source_file, &diagnostics) + defer ast.destroy_module(&module) + + found := false + for diagnostic in diagnostics.items { + found = found || strings.contains(diagnostic.message, "expected a struct field name") + } + testing.expect(t, found) +} + @(test) unbacked_enum_uses_global_u16_backing :: proc(t: ^testing.T) { builder := strings.builder_make()