diff --git a/TODO.md b/TODO.md index 9dd14df..73d1f71 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index 9c7a8b5..83612b7 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -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, diff --git a/compiler/ir/ir.odin b/compiler/ir/ir.odin index d2bd86e..c930c37 100644 --- a/compiler/ir/ir.odin +++ b/compiler/ir/ir.odin @@ -85,6 +85,8 @@ Opcode :: enum u8 { Length, Slice_Ptr, Unwrap, + Optional_Is_Some, + Optional_Value, Orelse_Begin, Orelse, Widen, diff --git a/compiler/lexer/lexer.odin b/compiler/lexer/lexer.odin index 9e0a720..9326225 100644 --- a/compiler/lexer/lexer.odin +++ b/compiler/lexer/lexer.odin @@ -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 diff --git a/compiler/llvm/llvm.odin b/compiler/llvm/llvm.odin index fa06690..2b692d8 100644 --- a/compiler/llvm/llvm.odin +++ b/compiler/llvm/llvm.odin @@ -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) diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index 463a573..9a6e5a5 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -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, diff --git a/compiler/parser/parser.odin b/compiler/parser/parser.odin index a2d234c..b45192e 100644 --- a/compiler/parser/parser.odin +++ b/compiler/parser/parser.odin @@ -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, diff --git a/compiler/token/token.odin b/compiler/token/token.odin index 63a13f6..cc71fd2 100644 --- a/compiler/token/token.odin +++ b/compiler/token/token.odin @@ -40,6 +40,7 @@ Kind :: enum u8 { Left_Brace, Right_Brace, Comma, + Pipe, Keyword_Func, Keyword_C_Func, Keyword_Struct, diff --git a/compiler_tests.odin b/compiler_tests.odin index f79ec1f..2f337dc 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -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) +} diff --git a/examples/programs/conditional_unwrap/main.bro b/examples/programs/conditional_unwrap/main.bro new file mode 100644 index 0000000..17bd449 --- /dev/null +++ b/examples/programs/conditional_unwrap/main.bro @@ -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 +}