From 9c6215776eb81898ab5ca00e0995c0a9ac665133 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Wed, 22 Jul 2026 00:44:35 +0200 Subject: [PATCH] add constcast and immutable free --- LANGUAGE.md | 1 + TODO.md | 18 ++- compiler/checker/checker.odin | 39 +++++ compiler/checker/comptime.odin | 3 + compiler/hir/hir.odin | 1 + compiler/ir/ir.odin | 1 + compiler/llvm/llvm.odin | 12 +- compiler/lower/lower.odin | 3 +- compiler/types/types.odin | 39 +++++ compiler_tests.odin | 142 ++++++++++++++++++ std/mem/mem.bro | 5 +- .../test/highlight/intrinsics.bro | 3 + 12 files changed, 256 insertions(+), 11 deletions(-) diff --git a/LANGUAGE.md b/LANGUAGE.md index abe90bc..bc41e75 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -34,6 +34,7 @@ roadmap and milestone history. - pointer mutability via `mut`, optional pointers as nullable pointers, pointer arithmetic for many-item pointers, postfix dereference `^`, and trapping optional unwrap `?` - pointer-to-array `.len`, indexing, slicing, `.ptr` on slices and pointers-to-arrays, implicit address-taking for array-variable slices, and pointer/slice sentinel weakening - `ptrcast!(T, ptr)` as a first-pass pointer-child retype that preserves optionality, pointer kind, mutability, and sentinel shape +- unsafe `constcast!(value)` for restoring mutability to pointers, optional pointers, and slices without changing their child type or shape - UTF-8 string literals as immutable pointers to static zero-terminated byte arrays, plus raw backtick multiline strings - narrow immutable zero-terminated byte pointer/slice conversion to `*c_char` / `?*c_char` without general `u8`/`c_char` interchange - optionals with `null`, `orelse`, postfix `?`, conditional unwraps, guarded unwraps, and left-to-right short-circuiting multi-unwraps diff --git a/TODO.md b/TODO.md index 9ea4cf0..0c1ba20 100644 --- a/TODO.md +++ b/TODO.md @@ -960,6 +960,11 @@ value-producing control-flow forms with or without an error capture - void fallthrough and diverging `noreturn` fallbacks remain valid +50. add `constcast!` and immutable deallocation (implemented) + - `constcast!` restores mutability only for pointers, optional pointers, and slices while + preserving child type, pointer kind, optionality, length, and sentinel shape + - `std/mem.free` accepts immutable slices and restores mutability only at the allocator boundary + ## A word on unchecked casts For casts that bypass safety checks, Honey provides builtin functions: @@ -968,7 +973,8 @@ For casts that bypass safety checks, Honey provides builtin functions: | -- | -- | -- | | `truncate(x, T)` | Keep low bits, discard rest | Never | | `bitcast(x, T)` | Reinterpret bits, no cast | Sizes don't match (compile error) | -| `ptrcast!(p, T)` | Change pointer type | Gaining mutability (compile error) | +| `ptrcast!(T, p)` | Change a pointer's child type while preserving its shape | Invalid child or non-pointer operand (compile error) | +| `constcast!(p)` | Restore pointer or slice mutability | Non-pointer/slice operand (compile error) | ```honey # truncation @@ -981,12 +987,10 @@ m := bitcast(n, u32) # m == 0xFFFFFFFF (same bits) f: f32 = 3.14 bits := bitcast(f, u32) # IEEE 754 representation -# pointer casts (element type, many ↔ single, pointer ↔ usize) -buf: *u8 = get_buffer() -ints := ptrcast!(buf, *u32) # element type change -single := ptrcast!(buf, @u8) # many → single (restricting) -addr := ptrcast!(buf, usize) # pointer to integer -ptr := ptrcast!(addr, @u8) # integer to pointer +# pointer casts +buf *u8 = get_buffer() +ints *u32 = ptrcast!(u32, buf) # element type change, same pointer shape +writable *mut u8 = constcast!(buf) # explicit unsafe mutability restoration ``` ## A word on multi-unwrap diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index dbc33c9..9638e84 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -5187,6 +5187,16 @@ infer_expr :: proc( _ = pop(&stack) continue } + if is_intrinsic_call(checker, expr, "constcast") { + if len(expr.args) == 1 { + operand := infer_nested_expr(checker, expr.args[0], locals, pkg, file, demanded, local_types) + last, _ = types.restore_mutability(&checker.module.types, operand) + } else { + last = types.INVALID + } + _ = pop(&stack) + continue + } if is_intrinsic_call(checker, expr, "compile_error") { last = types.VOID _ = pop(&stack) @@ -9056,6 +9066,17 @@ build_expr :: proc( append(&stack, Build_Expr_Frame{expr=expr.args[1], expected=types.INVALID, template=ast.INVALID_FUNCTION}) continue } + if is_intrinsic_call(checker, expr, "constcast") { + if len(expr.args) != 1 { + id := source.addf(checker.diagnostics, expr.span, "constcast! expects 1 argument, got %d", len(expr.args)) + last = invalid_hir_expr(checker, expr.span, id) + _ = pop(&stack) + continue + } + stack[frame_index].stage = 10 + append(&stack, Build_Expr_Frame{expr=expr.args[0], expected=types.INVALID, template=ast.INVALID_FUNCTION}) + continue + } if is_intrinsic_call(checker, expr, "compile_error") { message := "compile_error! requires one comptime string argument" if len(expr.args) == 1 { @@ -9716,6 +9737,24 @@ build_expr :: proc( } _ = pop(&stack) } + if frame.stage == 10 { + result, ok := types.restore_mutability(&checker.module.types, checker.module.exprs[last].type) + if !ok { + id := source.add(checker.diagnostics, expr.span, "constcast! operand must be a pointer, optional pointer, or slice") + last = invalid_hir_expr(checker, expr.span, id) + } else { + last = add_hir_expr(checker, hir.Expr{ + kind=.Const_Cast, + span=expr.span, + type=result, + left=last, + target=hir.INVALID_REF, + right=hir.INVALID_EXPR, + diagnostic=source.INVALID_DIAGNOSTIC, + }) + } + _ = pop(&stack) + } } return last } diff --git a/compiler/checker/comptime.odin b/compiler/checker/comptime.odin index 41d1b01..fdeaa29 100644 --- a/compiler/checker/comptime.odin +++ b/compiler/checker/comptime.odin @@ -3243,6 +3243,9 @@ ct_eval_call_expr :: proc(state: ^Ct_State, expr: ast.Expr, expected: types.Type if builtin := memory_builtin_call(checker, expr); builtin != .None { return ct_eval_memory_call(state, expr, builtin, depth+1) } + if is_intrinsic_call(checker, expr, "constcast") { + return INVALID_CT_VALUE, ct_flow(.Normal), ct_fail(state, .Not_Comptime, expr.span, "constcast! is not available during comptime evaluation") + } if expr.intrinsic { if symbol.is_valid(expr.qualifier) { return INVALID_CT_VALUE, ct_flow(.Normal), ct_fail(state, .Not_Comptime, expr.span, "intrinsic calls must be unqualified") diff --git a/compiler/hir/hir.odin b/compiler/hir/hir.odin index e3ed2dd..f73fe07 100644 --- a/compiler/hir/hir.odin +++ b/compiler/hir/hir.odin @@ -107,6 +107,7 @@ Expr_Kind :: enum u8 { Retype, Scalar_Cast, Pointer_Cast, + Const_Cast, Weaken_Pointer, Weaken_Slice, Decay_Array_Pointer, diff --git a/compiler/ir/ir.odin b/compiler/ir/ir.odin index 6169f6c..2e97b42 100644 --- a/compiler/ir/ir.odin +++ b/compiler/ir/ir.odin @@ -102,6 +102,7 @@ Opcode :: enum u8 { Retype, Scalar_Cast, Pointer_Cast, + Const_Cast, Weaken_Pointer, Weaken_Slice, Decay_Array_Pointer, diff --git a/compiler/llvm/llvm.odin b/compiler/llvm/llvm.odin index 9439853..c39f3d7 100644 --- a/compiler/llvm/llvm.odin +++ b/compiler/llvm/llvm.odin @@ -287,7 +287,7 @@ valid_value :: proc( .Load_Global, .Function_Address, .Address_Of, .Load, .Union_Tag, .Slice, .Length, .Slice_Ptr, .Fallible_Error, .Extract, .Select, .Unwrap, .Optional_Is_Some, .Optional_Value, .Orelse, - .Widen, .Sum_Widen, .C_Coerce, .C_Vararg_Promote, .Retype, .Scalar_Cast, .Pointer_Cast, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer, + .Widen, .Sum_Widen, .C_Coerce, .C_Vararg_Promote, .Retype, .Scalar_Cast, .Pointer_Cast, .Const_Cast, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer, .Neg_Checked, .Add_Checked, .Sub_Checked, .Mul_Checked, .Div_Checked, .Div_Trunc_Checked, .Div_Floor_Checked, .Div_Exact_Checked, .Div_Ceil_Checked, .Rem_Checked, .Mod_Checked, @@ -1930,6 +1930,16 @@ emit_instruction_stream :: proc( continue } fmt.sbprintf(&emitter.builder, " %%v%d = select i1 true, ptr %%v%d, ptr null\n", instruction_index, instruction.a) + case .Const_Cast: + if !valid_instruction(instructions, instruction.a) || + !types.same_constcast_shape(instructions[instruction.a].type, instruction.type, &emitter.module.types) { + emit_recovery_value(emitter, instruction_index, instruction, "invalid const cast operand") + continue + } + type_name := llvm_type(instruction.type, &emitter.module.types) + fmt.sbprintf(&emitter.builder, " %%v%d = select i1 true, %s ", instruction_index, type_name) + write_operand(&emitter.builder, instructions, instruction.a, instructions[instruction.a].type, &emitter.module.types) + fmt.sbprintf(&emitter.builder, ", %s zeroinitializer\n", type_name) case .Weaken_Slice: if !valid_instruction(instructions, instruction.a) || !types.can_weaken_slice(instructions[instruction.a].type, instruction.type, &emitter.module.types) { diff --git a/compiler/lower/lower.odin b/compiler/lower/lower.odin index 33ad731..51bb839 100644 --- a/compiler/lower/lower.odin +++ b/compiler/lower/lower.odin @@ -768,7 +768,7 @@ lower_expr :: proc(state: ^State, expr_id: hir.Expr_Id) -> ir.Instruction_Id { }) } _ = pop(&stack) - case .Widen, .Sum_Widen, .C_Coerce, .C_Vararg_Promote, .Retype, .Scalar_Cast, .Pointer_Cast, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer: + case .Widen, .Sum_Widen, .C_Coerce, .C_Vararg_Promote, .Retype, .Scalar_Cast, .Pointer_Cast, .Const_Cast, .Weaken_Pointer, .Weaken_Slice, .Decay_Array_Pointer: stack[frame_index].stage = 1 append(&stack, Lower_Expr_Frame{expr=expr.left}) case .Negate, .Bit_Not: @@ -837,6 +837,7 @@ lower_expr :: proc(state: ^State, expr_id: hir.Expr_Id) -> ir.Instruction_Id { case .Retype: op = .Retype case .Scalar_Cast: op = .Scalar_Cast case .Pointer_Cast: op = .Pointer_Cast + case .Const_Cast: op = .Const_Cast case: op = .Widen } last = append_instruction(state, ir.Instruction{ diff --git a/compiler/types/types.odin b/compiler/types/types.odin index c270cb5..a1b5d71 100644 --- a/compiler/types/types.odin +++ b/compiler/types/types.odin @@ -1340,6 +1340,45 @@ replace_pointer_child :: proc(store: ^Store, value, child: Type) -> (Type, bool) return intern(store, item), true } +restore_mutability :: proc(store: ^Store, value: Type) -> (Type, bool) { + item, ok := node(store, resolve_alias(value, store)) + if !ok { + return INVALID, false + } + if item.kind == .Optional { + restored, restored_ok := restore_mutability(store, item.child) + if !restored_ok || !is_pointer(restored, store) { + return INVALID, false + } + return optional(store, restored), true + } + if item.kind != .Pointer && item.kind != .Slice { + return INVALID, false + } + item.mutable = true + return intern(store, item), true +} + +same_constcast_shape :: proc(from, to: Type, store: ^Store) -> bool { + from_item, from_ok := node(store, resolve_alias(from, store)) + to_item, to_ok := node(store, resolve_alias(to, store)) + if !from_ok || !to_ok { + return false + } + if from_item.kind == .Optional || to_item.kind == .Optional { + return from_item.kind == .Optional && to_item.kind == .Optional && + is_pointer(from_item.child, store) && is_pointer(to_item.child, store) && + same_constcast_shape(from_item.child, to_item.child, store) + } + return (from_item.kind == .Pointer || from_item.kind == .Slice) && + from_item.kind == to_item.kind && + from_item.child == to_item.child && + from_item.many == to_item.many && + to_item.mutable && + from_item.has_sentinel == to_item.has_sentinel && + (!from_item.has_sentinel || from_item.sentinel == to_item.sentinel) +} + same_pointer_shape :: proc(left, right: Type, store: ^Store) -> bool { left_item, left_ok := node(store, left) right_item, right_ok := node(store, right) diff --git a/compiler_tests.odin b/compiler_tests.odin index c001710..a2a012e 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -2184,6 +2184,148 @@ main func() void { testing.expect(t, found_target_type) } +@(test) +constcast_restores_pointer_and_slice_mutability :: proc(t: ^testing.T) { + text := `main func() i32 { + values [2]mut u8 = [1, 2] + immutable_slice []u8 = values[..] + mutable_slice []mut u8 = constcast!(immutable_slice) + mutable_slice[0] = 3 + + immutable_many *u8 = immutable_slice.ptr + mutable_many *mut u8 = constcast!(immutable_many) + mutable_many[1] = 4 + + number i32 = 5 + immutable_single @i32 = &number + mutable_single @mut i32 = constcast!(immutable_single) + mutable_single^ = 6 + + maybe ?@i32 = immutable_single + mutable_maybe ?@mut i32 = constcast!(maybe) + if mutable_maybe |pointer| { pointer^ = 7 } + + already_mutable []mut u8 = constcast!(mutable_slice) + if already_mutable[0] != 3 or already_mutable[1] != 4 or number != 7 { return 1 } + return 0 +} +` + source_file := source.Source{path="constcast.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) + + hir_casts, ir_casts := 0, 0 + for expr in hir_module.exprs { + hir_casts += 1 if expr.kind == .Const_Cast else 0 + } + for function in ir_module.functions { + for instruction in function.instructions { + ir_casts += 1 if instruction.op == .Const_Cast else 0 + } + } + testing.expect_value(t, len(diagnostics.items), 0) + testing.expect_value(t, hir_casts, 5) + testing.expect_value(t, ir_casts, 5) + + directory := "/tmp/brolang-test-constcast" + main_path := "/tmp/brolang-test-constcast/main.bro" + output := "/tmp/brolang-test-constcast-output" + _ = 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), 0) + state := run_executable(output) + testing.expect_value(t, state.exit_code, 0) +} + +@(test) +invalid_constcasts_are_rejected :: proc(t: ^testing.T) { + text := `main func() void { + values [2]mut u8 = [1, 2] + _ = constcast!() + _ = constcast!(1, 2) + _ = constcast!(1) + _ = constcast!(values) +} +` + source_file := source.Source{path="invalid_constcast.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) + + bad_arity, bad_operand := false, false + for diagnostic in diagnostics.items { + bad_arity = bad_arity || strings.contains(diagnostic.message, "constcast! expects 1 argument") + bad_operand = bad_operand || strings.contains(diagnostic.message, "constcast! operand must be a pointer, optional pointer, or slice") + } + testing.expect(t, bad_arity && bad_operand) +} + +@(test) +immutable_allocations_can_be_freed_and_constcast_keeps_slice_bounds_checks :: proc(t: ^testing.T) { + free_text := `mem :: import "@std/mem" +main func() i32 { + memory []mut u8 :: mem.alloc(u8, mem.c_allocator, 4) catch |_| { return 1 } + memory[0] = 42 + immutable []u8 = memory + mem.free(mem.c_allocator, immutable) + empty []u8 = mem.empty(u8) + mem.free(mem.c_allocator, empty) + return 0 +} +` + directory := "/tmp/brolang-test-immutable-free" + main_path := "/tmp/brolang-test-immutable-free/main.bro" + output := "/tmp/brolang-test-immutable-free-output" + _ = 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)free_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) + + bounds_text := `main func() void { + values [2]mut u8 = [1, 2] + immutable []u8 = values[..] + mutable []mut u8 = constcast!(immutable) + _ = mutable[mutable.len] +} +` + testing.expect(t, os.write_entire_file(main_path, transmute([]byte)bounds_text)) + testing.expect_value(t, compiler_core.compile_package(directory, output), 0) + bounds_state, stdout, stderr, err := os2.process_exec( + os2.Process_Desc{command=[]string{output}}, context.allocator, + ) + defer delete(stdout) + defer delete(stderr) + testing.expect(t, err == nil) + testing.expect(t, !bounds_state.success) + testing.expect(t, strings.contains(string(stderr), "index out of bounds")) +} + @(test) old_intrinsic_spellings_are_not_recognized :: proc(t: ^testing.T) { text := `main func() void { diff --git a/std/mem/mem.bro b/std/mem/mem.bro index 71e9b39..1807bf8 100644 --- a/std/mem/mem.bro +++ b/std/mem/mem.bro @@ -110,9 +110,10 @@ realloc func($T type, allocator Allocator, memory []mut T, new_count usize) []mu return .out_of_memory } -free func($T type, allocator Allocator, memory []mut T) void { +free func($T type, allocator Allocator, memory []T) void { if memory.len != 0 and sizeof!(T) != 0 { - raw_free(allocator, ptrcast!(u8, memory.ptr), memory.len * sizeof!(T), alignof!(T)) + mutable_memory []mut T :: constcast!(memory) + raw_free(allocator, ptrcast!(u8, mutable_memory.ptr), memory.len * sizeof!(T), alignof!(T)) } } diff --git a/tree-sitter-brolang/test/highlight/intrinsics.bro b/tree-sitter-brolang/test/highlight/intrinsics.bro index f20eda8..aa37d5c 100644 --- a/tree-sitter-brolang/test/highlight/intrinsics.bro +++ b/tree-sitter-brolang/test/highlight/intrinsics.bro @@ -10,4 +10,7 @@ main func() void { # ^ operator _ = sizeof(i32) # ^^^^^^ function + _ = constcast!(memory) +# ^^^^^^^^^ function.builtin +# ^ operator }