conditional optional unwrapping

This commit is contained in:
2026-06-21 20:38:12 +02:00
parent c90ada608e
commit f4194492cc
10 changed files with 391 additions and 12 deletions
+8 -2
View File
@@ -80,7 +80,7 @@
- function-like macros and non-literal macro expressions remain unsupported
- static inline functions (implemented)
- 5. control flow
5. control flow
- boolean expressions (implemented)
- `bool` type with `true` / `false` literals
- comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=` (numeric operands widen; `bool` supports only `==` / `!=`)
@@ -89,7 +89,10 @@
- if statements (implemented). example: `if condition { ... } else if { ... } else { ... }`
- conditions must be `bool`; block-scoped locals with shadowing across blocks
- lowered through new `Label` / `Br` / `Cond_Br` IR opcodes (alloca-backed locals, no phi nodes)
- conditional unwrapping for optionals (`?T`): `if val |v| { ... } else { ... }` - unwrap `val` into `v` if it is not `none`
- conditional unwrapping for optionals (`?T`) (implemented): `if val |v| { ... } else { ... }` - unwrap `val` into `v` if it is not `none`
- single immutable binding scoped to the then-block; `v` not visible in `else` or after the `if`
- `|` lexes as a new `Pipe` token; the `.If` reuses AST `name` / HIR `local` to carry the binding (no new statement kind)
- new `Optional_Is_Some` / `Optional_Value` IR opcodes (the `Unwrap` presence-test + extract, minus the trap)
- conditional unwrapping with guard clause: `if val |v : v >= 10| { ... } else { ... }` - unwrap `val` into `v` if it is not `none`
- multi-unwrap (see section below)
- while loops (operates on boolean conditions). examples:
@@ -106,6 +109,9 @@
- `for 0..(len) |i| { ... }` or equivalently `for 0..=(len - 1) |i| { ... }` - calculating range bounds, expressions must be parenthesized
- for all conditionals/guards, parentheses are optional but allowed for visual clarity
6. compound assignment
## A word on multi-unwrap
Unwrap multiple optionals with `and`. This **short-circuits**: if the first optional is none, subsequent expressions are not evaluated.
+69 -8
View File
@@ -58,7 +58,6 @@ Build_Ctx :: struct {
global_reads: ^[dynamic]hir.Global_Id,
calls: ^[dynamic]hir.Function_Id,
problematic: ^bool,
has_return: ^bool,
}
Constant_Kind :: enum {
@@ -1412,9 +1411,19 @@ infer_statements :: proc(
}
}
case .If:
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
infer_statements(checker, statement.body, locals, pkg, file, demanded, result)
infer_statements(checker, statement.else_body, locals, pkg, file, demanded, result)
value_type := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
if statement.name != symbol.INVALID {
// Conditional unwrap: `v` is in scope (with the unwrapped type) only inside the then-block.
child := types.child_type(value_type, &checker.module.types) if types.is_optional(value_type, &checker.module.types) else types.INVALID
binding_start := len(locals^)
append(locals, Infer_Local{name = statement.name, type = child})
infer_statements(checker, statement.body, locals, pkg, file, demanded, result)
resize(locals, binding_start)
infer_statements(checker, statement.else_body, locals, pkg, file, demanded, result)
} else {
infer_statements(checker, statement.body, locals, pkg, file, demanded, result)
infer_statements(checker, statement.else_body, locals, pkg, file, demanded, result)
}
}
}
resize(locals, scope_start)
@@ -2949,7 +2958,6 @@ build_block :: proc(ctx: ^Build_Ctx, statements: []ast.Stmt_Id) -> []hir.Stmt_Id
})
ctx.problematic^ = ctx.problematic^ || checker.module.exprs[value].kind == .Invalid
case .Return:
ctx.has_return^ = true
if statement.expr == ast.INVALID_EXPR {
if !types.is_void(ctx.result) {
id := source.add(checker.diagnostics, statement.span, "'return _' is only valid in a void function")
@@ -3007,6 +3015,39 @@ build_block :: proc(ctx: ^Build_Ctx, statements: []ast.Stmt_Id) -> []hir.Stmt_Id
})
}
case .If:
if statement.name != symbol.INVALID {
// Conditional unwrap `if opt |v| { ... }`: `expr` is the optional, `v`
// binds the unwrapped value (immutable) for the duration of the then-block.
value := build_expr(checker, statement.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.INVALID, ctx.pkg, ctx.file)
value_type := checker.module.exprs[value].type
child := types.INVALID
if checker.module.exprs[value].kind != .Invalid && !types.is_optional(value_type, &checker.module.types) {
id := source.add(checker.diagnostics, statement.span, "'if' unwrap requires an optional value")
value = invalid_hir_expr(checker, statement.span, id)
ctx.problematic^ = true
} else if checker.module.exprs[value].kind != .Invalid {
child = types.child_type(value_type, &checker.module.types)
}
binding := hir.local_id(len(ctx.hir_locals^))
append(ctx.hir_locals, hir.Local{name = statement.name, type = child, mutable = false})
locals_before := len(ctx.locals^)
append(ctx.locals, Build_Local{name = statement.name, type = child, mutable = false, id = binding})
then_body := build_block(ctx, statement.body)
resize(ctx.locals, locals_before)
else_body: []hir.Stmt_Id = nil
if statement.else_body != nil {
else_body = build_block(ctx, statement.else_body)
}
append(&body, hir.stmt_id(len(checker.module.statements)))
append(&checker.module.statements, hir.Stmt{
kind = .If, span = statement.span, expr = value,
then_body = then_body, else_body = else_body,
local = binding, target = hir.INVALID_EXPR,
diagnostic = source.INVALID_DIAGNOSTIC,
})
ctx.problematic^ = ctx.problematic^ || checker.module.exprs[value].kind == .Invalid
continue
}
condition := build_expr(checker, statement.expr, ctx.locals^[:], ctx.global_reads, ctx.calls, types.BOOL, ctx.pkg, ctx.file)
if checker.module.exprs[condition].kind != .Invalid && !types.is_bool(checker.module.exprs[condition].type) {
id := source.add(checker.diagnostics, statement.span, "'if' condition must be a bool")
@@ -3039,6 +3080,27 @@ build_block :: proc(ctx: ^Build_Ctx, statements: []ast.Stmt_Id) -> []hir.Stmt_Id
return body[:]
}
// Reports whether every control-flow path through `stmts` terminates (returns or traps),
// so the end of the block is unreachable. A `.Return` or `.Trap` terminates outright; an
// `.If` terminates only when it has an `else` and both arms terminate. Recursion into the
// `then_body`/`else_body` slices handles nested ifs and `else if` chains.
all_paths_return :: proc(module: ^hir.Module, stmts: []hir.Stmt_Id) -> bool {
for id in stmts {
statement := module.statements[id]
#partial switch statement.kind {
case .Return, .Trap:
return true
case .If:
if statement.else_body != nil &&
all_paths_return(module, statement.then_body) &&
all_paths_return(module, statement.else_body) {
return true
}
}
}
return false
}
build_function :: proc(checker: ^Checker, id: Spec_Id) {
spec := checker.specs[id]
function := checker.ast_module.functions[spec.template]
@@ -3118,7 +3180,6 @@ build_function :: proc(checker: ^Checker, id: Spec_Id) {
return
}
has_return := false
if signature_diagnostic != source.INVALID_DIAGNOSTIC {
append(&body, hir.stmt_id(len(checker.module.statements)))
append(
@@ -3142,15 +3203,15 @@ build_function :: proc(checker: ^Checker, id: Spec_Id) {
global_reads = &global_reads,
calls = &calls,
problematic = &problematic,
has_return = &has_return,
}
block := build_block(&ctx, function.body)
returns := all_paths_return(&checker.module, block)
for block_stmt in block {
append(&body, block_stmt)
}
delete(block, checker.allocator)
if !types.is_void(spec.result) && !has_return {
if !types.is_void(spec.result) && !returns {
id := source.addf(
checker.diagnostics,
function.span,
+2
View File
@@ -85,6 +85,8 @@ Opcode :: enum u8 {
Length,
Slice_Ptr,
Unwrap,
Optional_Is_Some,
Optional_Value,
Orelse_Begin,
Orelse,
Widen,
+3
View File
@@ -204,6 +204,9 @@ lex :: proc(
case ',':
append_token(&stream, source_file, .Comma, cursor, cursor+1)
cursor += 1
case '|':
append_token(&stream, source_file, .Pipe, cursor, cursor+1)
cursor += 1
case '"':
start := cursor
cursor += 1
+26 -1
View File
@@ -232,7 +232,8 @@ 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, .Unwrap, .Orelse,
.Load_Global, .Function_Address, .Address_Of, .Load, .Slice, .Length, .Slice_Ptr, .Unwrap,
.Optional_Is_Some, .Optional_Value, .Orelse,
.Widen, .C_Vararg_Promote, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer,
.Neg_Checked, .Add_Checked, .Pointer_Add, .Not, .Compare, .Call:
return true
@@ -928,6 +929,30 @@ emit_instruction_stream :: proc(
} else {
fmt.sbprintf(&emitter.builder, " %%v%d = extractvalue %s %%v%d, 1\n", instruction_index, llvm_type(optional_type, &emitter.module.types), instruction.a)
}
case .Optional_Is_Some:
optional_type := instructions[instruction.a].type if valid_instruction(instructions, instruction.a) else types.INVALID
item, ok := types.node(&emitter.module.types, optional_type)
if !ok || item.kind != .Optional {
emit_recovery_value(emitter, instruction_index, instruction, "invalid optional presence test")
continue
}
if types.is_pointer(item.child, &emitter.module.types) {
fmt.sbprintf(&emitter.builder, " %%v%d = icmp ne ptr %%v%d, null\n", instruction_index, instruction.a)
} else {
fmt.sbprintf(&emitter.builder, " %%v%d = extractvalue %s %%v%d, 0\n", instruction_index, llvm_type(optional_type, &emitter.module.types), instruction.a)
}
case .Optional_Value:
optional_type := instructions[instruction.a].type if valid_instruction(instructions, instruction.a) else types.INVALID
item, ok := types.node(&emitter.module.types, optional_type)
if !ok || item.kind != .Optional || !types.equal(item.child, instruction.type) {
emit_recovery_value(emitter, instruction_index, instruction, "invalid optional value")
continue
}
if types.is_pointer(item.child, &emitter.module.types) {
fmt.sbprintf(&emitter.builder, " %%v%d = select i1 true, ptr %%v%d, ptr null\n", instruction_index, instruction.a)
} else {
fmt.sbprintf(&emitter.builder, " %%v%d = extractvalue %s %%v%d, 1\n", instruction_index, llvm_type(optional_type, &emitter.module.types), instruction.a)
}
case .Orelse_Begin:
optional_type := instructions[instruction.a].type if valid_instruction(instructions, instruction.a) else types.INVALID
item, ok := types.node(&emitter.module.types, optional_type)
+36 -1
View File
@@ -633,7 +633,22 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) {
diagnostic=statement.diagnostic,
})
case .If:
cond := lower_expr(state, statement.expr)
// A conditional unwrap (`if opt |v| { ... }`) carries the binding local id in
// `statement.local`; `expr` is then the optional, not a bool condition. Test it
// for presence, and inside the then-block bind the unwrapped value to the local.
is_unwrap := statement.local != hir.INVALID_LOCAL
opt := ir.INVALID_INSTRUCTION
cond: ir.Instruction_Id
if is_unwrap {
opt = lower_expr(state, statement.expr)
cond = append_instruction(state, ir.Instruction{
op=.Optional_Is_Some, span=statement.span, type=types.BOOL,
target=ir.INVALID_REF, a=opt, b=ir.INVALID_INSTRUCTION,
diagnostic=source.INVALID_DIAGNOSTIC,
})
} else {
cond = lower_expr(state, statement.expr)
}
has_else := statement.else_body != nil
then_lbl := fresh_label(state)
else_lbl := fresh_label(state) if has_else else then_lbl
@@ -649,6 +664,26 @@ lower_statements :: proc(state: ^State, statements: []hir.Stmt_Id) {
target=ir.INVALID_REF, a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION,
diagnostic=source.INVALID_DIAGNOSTIC,
})
if is_unwrap && int(statement.local) < len(state.func_locals) {
local := state.func_locals[statement.local]
slot := append_instruction(state, ir.Instruction{
op=.Alloca, span=statement.span, type=local.type,
target=ir.local_ref(ir.Local_Id(statement.local)),
a=ir.INVALID_INSTRUCTION, b=ir.INVALID_INSTRUCTION,
diagnostic=source.INVALID_DIAGNOSTIC,
})
state.local_slots[statement.local] = slot
inner := append_instruction(state, ir.Instruction{
op=.Optional_Value, span=statement.span, type=local.type,
target=ir.INVALID_REF, a=opt, b=ir.INVALID_INSTRUCTION,
diagnostic=source.INVALID_DIAGNOSTIC,
})
append_instruction(state, ir.Instruction{
op=.Store, span=statement.span, type=local.type,
target=ir.INVALID_REF, a=slot, b=inner,
diagnostic=source.INVALID_DIAGNOSTIC,
})
}
lower_statements(state, statement.then_body)
append_instruction(state, ir.Instruction{
op=.Br, span=statement.span, type=types.VOID, integer=merge_lbl,
+13
View File
@@ -1079,6 +1079,18 @@ parse_if :: proc(parser: ^Parser) -> ast.Stmt_Id {
parser.no_struct_literal = true
condition := parse_expression(parser)
parser.no_struct_literal = saved
binding := symbol.INVALID
if _, ok := allow(parser, .Pipe); ok {
name_tok, name_ok := allow(parser, .Identifier)
if name_ok {
binding = name_tok.symbol
} else {
source.add(parser.diagnostics, current(parser).span, "expected a binding name after '|'")
}
if _, close_ok := allow(parser, .Pipe); !close_ok {
source.add(parser.diagnostics, current(parser).span, "expected '|' to close the unwrap binding")
}
}
skip_newlines(parser)
then_body := parse_block(parser)
else_body: []ast.Stmt_Id = nil
@@ -1102,6 +1114,7 @@ parse_if :: proc(parser: ^Parser) -> ast.Stmt_Id {
append(&parser.module.statements, ast.Stmt{
kind=.If,
span=span_from(start.span, previous(parser).span),
name=binding,
expr=condition,
body=then_body,
else_body=else_body,
+1
View File
@@ -40,6 +40,7 @@ Kind :: enum u8 {
Left_Brace,
Right_Brace,
Comma,
Pipe,
Keyword_Func,
Keyword_C_Func,
Keyword_Struct,
+195
View File
@@ -3710,3 +3710,198 @@ deeply_nested_child_packages_load_recursively :: proc(t: ^testing.T) {
state := run_executable(output)
testing.expect_value(t, state.exit_code, 9)
}
@(test)
function_returning_only_in_if_branch_is_diagnosed :: proc(t: ^testing.T) {
text := `classify :: func(n i32) i32 {
if n > 0 {
return 1
}
}
main :: func() void {
_ = classify(5)
}
`
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 := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "does not return a value")
}
testing.expect(t, found)
}
@(test)
function_returning_in_both_if_arms_is_accepted :: proc(t: ^testing.T) {
text := `classify :: func(n i32) i32 {
if n > 0 {
return 1
} else {
return 0
}
}
main :: func() void {
_ = classify(5)
}
`
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 := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "does not return a value")
}
testing.expect(t, !found)
}
@(test)
function_returning_after_if_is_accepted :: proc(t: ^testing.T) {
text := `classify :: func(n i32) i32 {
if n > 0 {
return 1
}
return 0
}
main :: func() void {
_ = classify(5)
}
`
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 := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "does not return a value")
}
testing.expect(t, !found)
}
@(test)
conditional_unwrap_compiles_and_runs :: proc(t: ^testing.T) {
output := "/tmp/brolang-test-conditional-unwrap"
defer _ = os.remove(output)
status := compiler_core.compile_package("examples/programs/conditional_unwrap", output)
testing.expect_value(t, status, 0)
state := run_executable(output)
// present scalar binds and unwraps (40), none takes the else (+2), a present
// optional pointer binds and derefs (+0), a none optional pointer is skipped.
testing.expect_value(t, state.exit_code, 42)
}
@(test)
if_unwrap_on_non_optional_is_diagnosed :: proc(t: ^testing.T) {
text := `main :: func() i32 {
x i32 = 5
if x |v| {
return v
}
return 0
}
`
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 := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "unwrap requires an optional")
}
testing.expect(t, found)
}
@(test)
if_unwrap_binding_is_scoped_to_then_block :: proc(t: ^testing.T) {
// The binding `v` is usable in the then-block but not in the else-block.
text := `main :: func() i32 {
a ?i32 = 1
if a |v| {
return v
} else {
return v
}
}
`
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 := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "unresolved global 'v'")
}
testing.expect(t, found)
}
@(test)
if_unwrap_binding_is_immutable :: proc(t: ^testing.T) {
text := `main :: func() i32 {
a ?i32 = 1
if a |v| {
v = 2
return v
}
return 0
}
`
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 := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "cannot assign immutable local 'v'")
}
testing.expect(t, found)
}
@@ -0,0 +1,38 @@
# Milestone 5: conditional unwrapping `if opt |v| { ... }`.
# Tests a present optional binds and unwraps, a none takes the else branch, and
# that optional pointers (?*T) unwrap the same way.
main :: func() i32 {
total i32 = 0
# present optional scalar -> binds v to the unwrapped value
a ?i32 = 40
if a |v| {
total = total + v # 40
} else {
total = total + 99
}
# none -> else branch taken; the binding is not in scope there
b ?i32 = none
if b |v| {
total = total + v
} else {
total = total + 2 # 42
}
# optional pointer present -> binds q to a non-null @i32; deref proves it
n i32 = 0
p ?@i32 = &n
if p |q| {
total = total + q^ # +0
}
# optional pointer none -> skipped
z ?@i32 = none
if z |q| {
total = total + 1000
}
return total # expect exit code 42
}