From ecb7b2fc6469758cf01d9387db453d18bf5e66f7 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 25 Oct 2026 01:09:27 +0200 Subject: [PATCH] feat: contextually type empty mutable slice literals --- LANGUAGE.md | 11 ++++ compiler/checker/checker.odin | 45 ++++++++++++---- compiler_tests.odin | 96 +++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 10 deletions(-) diff --git a/LANGUAGE.md b/LANGUAGE.md index fbf855b..2ea40ef 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -50,6 +50,17 @@ roadmap and milestone history. - fallible channel types `T ! E`, where `E` is a native enum, native struct, tagged union, or supported sum composition; `void ! E` functions complete successfully on fallthrough, and void-success `catch` handlers may fall through without `yield` - bodyful local functions and root `main` may write `T!` to infer a specialization-local error channel from propagated `try` expressions and concretely typed error returns; inference composes only existing named error types, never synthesizes variants, and requires at least one inferred error +#### contextual empty mutable slices + +A fresh empty array literal address `&[]` may initialize an expected `[]mut T`, +including local declarations, call arguments, and record fields. The context supplies +the element type `T`; the resulting slice has length zero and no writable elements. +This is literal-only contextual typing, not a readonly-to-mutable conversion: +named readonly zero-length arrays, existing readonly empty slices, and nonempty +readonly literals still cannot initialize mutable slices. Sentinel slices `[;S]T` +and `[;S]mut T` still require backing storage containing the sentinel; `&[]` does +not provide it. + #### checked sentinel slicing `base[start..end;S]` constructs a sentinel slice `[;S]T` without copying elements. diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index 790948f..e19ccf0 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -1488,6 +1488,15 @@ is_numeric_constant_expr :: proc(checker: ^Checker, expr_id: ast.Expr_Id) -> boo return eval_constant(checker, expr_id).kind == .Value || is_float_constant_expr(checker, expr_id) } +is_empty_array_address :: proc(checker: ^Checker, expr: ast.Expr) -> bool { + if expr.kind != .Address || expr.left == ast.INVALID_EXPR || + int(expr.left) >= len(checker.ast_module.exprs) { + return false + } + operand := checker.ast_module.exprs[expr.left] + return operand.kind == .Array && len(operand.args) == 0 +} + is_typed_integer_fold_candidate :: proc(checker: ^Checker, expr_id: ast.Expr_Id, depth := 0) -> bool { if depth > 64 || expr_id == ast.INVALID_EXPR || int(expr_id) >= len(checker.ast_module.exprs) { return false @@ -3152,8 +3161,9 @@ infer_call_comptime_values :: proc( } arg_expr := checker.ast_module.exprs[arg_id] is_null := arg_expr.kind == .Null + // An empty literal supplies no element evidence ahead of concrete arguments. is_weak := is_numeric_constant_expr(checker, arg_id) || - arg_expr.kind == .String || is_null + arg_expr.kind == .String || is_null || is_empty_array_address(checker, arg_expr) contains_type_call := type_pattern_contains_type_call( checker, function.params[param_index].type, ) @@ -5220,6 +5230,13 @@ infer_compound_expr :: proc( _ = infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types) return type_from_syntax(checker, expr.type, pkg, file) case .Address: + if is_empty_array_address(checker, expr) { + if node, ok := types.node(store, expected); + ok && node.kind == .Slice && node.mutable && !node.has_sentinel { + child := types.array(store, node.child, 0, true) + return types.pointer(store, child, true, false) + } + } child := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types) return types.pointer(store, child, false, false) case .Deref: @@ -8758,11 +8775,9 @@ build_compound_expr :: proc( value := build_nested_expr(checker, expr.left, locals, global_reads, calls, types.INVALID, pkg, file) return build_scalar_cast(checker, value, target, expr.span) case .Address: - // `&` (Zig's `&.{...}`): the operand is an rvalue with no - // address, so promote it to an anonymous global constant and take *its* - // address. Reuses the existing non-scalar-global storage path; only the - // stable global address enters the expression, so it never dangles. The - // resulting `*[N]T` then decays to a slice via the usual coercion. + // Promote array literals to anonymous globals so their addresses never + // dangle. Only a fresh empty literal may acquire mutable slice context; + // ordinary literals retain readonly storage and the usual decay rules. if expr.left != ast.INVALID_EXPR && checker.ast_module.exprs[expr.left].kind == .Array { operand := checker.ast_module.exprs[expr.left] // Propagate an element-expected type through `&` so literal elements @@ -8770,12 +8785,15 @@ build_compound_expr :: proc( // Without this, `&["x"]` infers `*[1]*[N:0]u8`, which won't decay to // `[][]u8` because can_decay_array_pointer requires child equality. element := types.INVALID + mutable := false if node, ok := types.node(store, expected); ok && (node.kind == .Slice || node.kind == .Array) { element = node.child + mutable = len(operand.args) == 0 && node.kind == .Slice && + node.mutable && !node.has_sentinel } synth_expected := types.INVALID if types.is_valid(element) { - synth_expected = types.array(store, element, u64(len(operand.args)), false) + synth_expected = types.array(store, element, u64(len(operand.args)), mutable) } value := build_nested_expr(checker, expr.left, locals, global_reads, calls, synth_expected, pkg, file) array_type := checker.module.exprs[value].type @@ -8784,7 +8802,7 @@ build_compound_expr :: proc( name = symbol.intern(checker.symbols, "__anon.array"), type = array_type, expr = value, - writable = false, + writable = mutable, external = false, diagnostic = source.INVALID_DIAGNOSTIC, }) @@ -8794,7 +8812,7 @@ build_compound_expr :: proc( left=hir.INVALID_EXPR, right=hir.INVALID_EXPR, diagnostic=source.INVALID_DIAGNOSTIC, }) return add_hir_expr(checker, hir.Expr{ - kind=.Address, span=expr.span, type=types.pointer(store, array_type, false, false), + kind=.Address, span=expr.span, type=types.pointer(store, array_type, mutable, false), left=global_ref, target=hir.INVALID_REF, right=hir.INVALID_EXPR, diagnostic=source.INVALID_DIAGNOSTIC, }) } @@ -10389,14 +10407,21 @@ build_expr :: proc( checker.current_comptime_values = comptime_values for source_index in 0..= len(function.params) || function.params[param_index].comptime_value || - !is_numeric_constant_expr(checker, expr.args[source_index]) { + (!is_numeric_constant_expr(checker, expr.args[source_index]) && !empty_array) { continue } expected_arg := call_arg_expected(checker, function, param_index) if !is_runtime_type(checker, expected_arg) { continue } + if empty_array && types.can_decay_array_pointer( + checker.module.exprs[stack[frame_index].built_args[source_index]].type, + expected_arg, &checker.module.types, + ) { + continue + } rebuilt := build_nested_expr( checker, expr.args[source_index], locals, global_reads, calls, expected_arg, pkg, file, diff --git a/compiler_tests.odin b/compiler_tests.odin index 9cb7360..6540e38 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -1828,6 +1828,102 @@ immutable_pointer_and_slice_bindings_preserve_mutable_pointees :: proc(t: ^testi testing.expect_value(t, len(diagnostics.items), 0) } +@(test) +fresh_empty_literals_initialize_mutable_slices_and_grow :: proc(t: ^testing.T) { + directory := "/tmp/brolang-test-contextual-empty-slices" + main_path := "/tmp/brolang-test-contextual-empty-slices/main.bro" + output := "/tmp/brolang-test-contextual-empty-slices-output" + text := `mem :: import "@std/mem" +Item :: struct { value i32 } +Views :: struct { integers []mut i32, records []mut Item } +integers func(values []mut i32) usize { return values.len } +records func(values []mut Item) usize { return values.len } +generic func($T type, values []mut T, _ T) usize { return values.len } +main func() i32 { + numbers []mut i32 := &[] + items []mut Item := &[] + views Views := {integers = &[], records = &[]} + if numbers.len != 0 or items.len != 0 { return 1 } + if integers(&[]) != 0 or records(&[]) != 0 { return 2 } + if views.integers.len != 0 or views.records.len != 0 { return 3 } + integer i32 := 7 + item Item := {value = 9} + if generic(&[], integer) != 0 or generic(&[], item) != 0 { return 7 } + numbers = mem.realloc(mem.c_allocator, numbers, 2) catch |_| { return 4 } + defer mem.free(mem.c_allocator, numbers) + items = mem.realloc(mem.c_allocator, items, 1) catch |_| { return 5 } + defer mem.free(mem.c_allocator, items) + numbers[0] = 17 + numbers[1] = 25 + items[0] = {value = numbers[0] + numbers[1]} + if numbers.len != 2 or items.len != 1 or items[0].value != 42 { return 6 } + return 0 +} +` + _ = os2.remove_all(directory) + defer _ = os2.remove_all(directory) + defer _ = os.remove(output) + testing.expect(t, os.make_directory(directory) == nil) + testing.expect(t, os.write_entire_file(main_path, transmute([]byte)text)) + testing.expect_value(t, compiler_core.compile_package( + directory, output, nil, target.DEFAULT, cimport.Options{}, ".", + ), 0) + state := run_executable(output) + testing.expect_value(t, state.exit_code, 0) +} + +@(test) +empty_literal_mutability_does_not_weaken_readonly_or_sentinel_rules :: proc(t: ^testing.T) { + cases := [?]string { + `main func() void { + values [0]i32 := [] + view []mut i32 := &values + _ = view +} +`, + `main func() void { + view []mut i32 := &[1] + _ = view +} +`, + `main func() void { + readonly []i32 := &[] + view []mut i32 := readonly + _ = view +} +`, + `main func() void { + view [;0]i32 := &[] + _ = view +} +`, + `main func() void { + view [;0]mut i32 := &[] + _ = view +} +`, + } + for text in cases { + source_file := source.Source{path="invalid_empty_slice.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_conversion := false + for diagnostic in diagnostics.items { + found_conversion = found_conversion || + strings.contains(diagnostic.message, "cannot implicitly convert") + } + testing.expect(t, found_conversion, text) + } +} + @(test) many_item_pointer_slices_compile_and_run :: proc(t: ^testing.T) { directory := "/tmp/brolang-test-pointer-slices"