feat: materialize address-free compile-time untagged unions

Snapshot full union storage rather than choosing an overlapping field. Preserve structural union source syntax independently of nominal identity so direct literal arguments materialize and share normalized specialization keys. Reject uninitialized bytes and address-bearing member layouts.
This commit is contained in:
2026-10-30 15:54:32 +01:00
parent c8890b4355
commit cc39c96fc0
9 changed files with 420 additions and 25 deletions
+24 -1
View File
@@ -83,6 +83,7 @@ ExprKind :: enum {
try_expr
catch_expr
typed_constant
union_constant
comptime_expr
literal_void
literal_undefined
@@ -196,6 +197,7 @@ NodeKind :: enum {
stmt_errdefer
stmt_block
expr_typed_constant
expr_union_constant
expr_comptime
literal_float_value
literal_void
@@ -611,6 +613,17 @@ add_typed_constant proc(
}, at)
}
#! retains an untagged union's complete native-layout bytes as a string literal.
add_union_constant proc(
builder @mut Builder, at Span, type, bytes NodeId,
) NodeId ! mem.AllocError {
return try add_node(builder, Node{
kind = .expr_union_constant,
data0 = NodeData{ node_id = type },
data1 = NodeData{ node_id = bytes },
}, at)
}
add_comptime_expr proc(builder @mut Builder, at Span, operand NodeId) NodeId ! mem.AllocError {
return try add_node(builder, Node{
kind = .expr_comptime,
@@ -1599,6 +1612,7 @@ expression_kind proc(kind NodeKind) ExprKind {
.expr_try: .try_expr
.expr_catch: .catch_expr
.expr_typed_constant: .typed_constant
.expr_union_constant: .union_constant
.expr_comptime: .comptime_expr
.expr_block: .block_expr
.literal_float_value: .literal_float
@@ -1652,6 +1666,15 @@ typed_constant proc(tree @Tree, id NodeId) TypedConstantView {
}
}
union_constant proc(tree @Tree, id NodeId) TypedConstantView {
node :: get_node(&tree.storage, id)
debug.assert(node.kind == .expr_union_constant)
return TypedConstantView{
type = node.data0.node_id,
value = node.data1.node_id,
}
}
catch_expr proc(tree @Tree, id NodeId) CatchExprView {
node :: get_node(&tree.storage, id)
debug.assert(node.kind == .expr_catch)
@@ -2007,7 +2030,7 @@ node_layout proc(node Node) NodeLayout {
.literal_int, .literal_float, .literal_float_value, .literal_string, .literal_bool, .literal_null,
.literal_void, .literal_undefined, .expr_identifier, .invalid_expr: {}
.expr_unary: layout.direct_children = 2
.expr_binary, .expr_range, .expr_index, .expr_selection, .expr_typed_constant,
.expr_binary, .expr_range, .expr_index, .expr_selection, .expr_typed_constant, .expr_union_constant,
.expr_fallible_type, .expr_sum_type, .expr_array_type, .expr_slice_type:
layout.direct_children = 6
.expr_pointer_type: layout.direct_children = 4
+60
View File
@@ -1006,3 +1006,63 @@ computed_byte_slices_recompile_without_borrowing_evaluation_storage test {
try testing.expect_equal(i64(42), outcome.value.data.integer)
}
}
computed_untagged_unions_recompile_without_borrowing_storage_bytes test {
diagnostic_store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&diagnostic_store)
modules [2]mut ir.Module := undefined
{
session := init(mem.c_allocator)
defer deinit(&session)
root :: try add_package(&session)
dependency :: try add_package(&session)
bytes := try owned_text(
`lib :: import "../library"
`main proc() u64 {
` value := $(lib.make())
` saved :: value
` value.small = 0
` value.words[1] = 99
` if (saved.small != 9 or saved.bytes[1] != 255 or saved.bytes[7] != 255) return 0
` if (saved.bytes[17] != 255 or saved.bytes[23] != 255) return 0
` generated lib.Generated() :: {}
` if (generated.value.words[1] != 40 or generated.value.small != 9) return 0
` return saved.words[1] + saved.words[3]
`}
,
)
defer arraylist.deinit(&bytes)
root_source :: try add_source(&session, root, "/union_bytes/app/main.hon", &bytes, &diagnostic_store)
imported := try owned_text(
`Pair :: struct { small u8, large u64 }
`Storage :: union { small u8, rows [2]Pair, words [4]mut u64, bytes [32]u8 }
`make proc() Storage {
` value := Storage { words = [maxval!(u64), 40, maxval!(u64), 2] }
` value.small = 9
` return value
`}
`Generated proc() type {
` return struct_type!(.auto, ["value"], [Storage], [make()])
`}
,
)
defer arraylist.deinit(&imported)
_ = try add_source(&session, dependency, "/union_bytes/library/storage.hon", &imported, &diagnostic_store)
try arraylist.append(&session.imports, Import{
node = declaration(&session, root_source, 0).node,
target = dependency,
})
modules[0] = try compile(&session, root, &diagnostic_store)
errdefer ir.deinit(&modules[0])
modules[1] = try compile(&session, root, &diagnostic_store)
}
defer ir.deinit(&modules[0])
defer ir.deinit(&modules[1])
try testing.expect_equal(0, diagnostics.count(&diagnostic_store))
for modules |module| {
outcome := try interpreter.run(mem.c_allocator, &module, &diagnostic_store)
defer interpreter.deinit(&outcome)
try testing.expect(outcome.trap == null)
try testing.expect_equal(i64(42), outcome.value.data.integer)
}
}
+30
View File
@@ -1,3 +1,4 @@
import "@std/debug"
import "@std/mem"
import "@std/arraylist"
import "@std/strconv"
@@ -2097,6 +2098,35 @@ lower_expression proc(state @mut State, id ast.NodeId) ir.ValueId ! (mem.AllocEr
if (trap_type(state, id)) return ir.no_value
return try lower_as(state, ast.typed_constant(tree, id).value, typecheck.type_of(state.checked, id))
}
.union_constant: {
if (trap_type(state, id)) return ir.no_value
bytes :: ast.union_constant(tree, id).value
text :: ast.literal_text(tree, bytes)
count :: ir.storage_size(state.module, .record, shape)
byte_shape :: try ir.add_array(state.module, .u8, ir.no_array, count, false)
scratch :: state.arguments.items.len
defer state.arguments.items = state.arguments.items.ptr[..scratch]
cursor := usize(1)
while (cursor < text.len - 1) {
byte :: ast.string_next(text, &cursor) catch |_| {
trap(state, try diagnostics.add(state.diagnostics, .invalid_escape, span), span)
return ir.no_value
}
element :: try constant(state, ir.Value{ type = .u8, data = ir.ValueData{ unsigned = u64(byte) } }, span)
try arraylist.append(&state.arguments, element)
}
debug.assert(state.arguments.items.len - scratch == count)
block :: &state.module.functions.items[state.function].blocks.items[usize(state.block)]
start :: block.arguments.items.len
for (state.arguments.items[scratch..]) |element| try arraylist.append(&block.arguments, element)
payload :: try emit(state, ir.Instruction{
op = .array_literal, type = .array, array_type = byte_shape,
arguments_start = start, arguments_count = count, span = span,
})
return try emit(state, ir.Instruction{
op = .union_literal, type = .record, array_type = shape, left = payload, span = span,
})
}
.invalid: {
invalid :: ast.invalid(tree, id)
trap(state, invalid.diagnostic, invalid.span)
+23 -1
View File
@@ -60,6 +60,8 @@ RecordField :: struct {
RecordType :: struct {
declaration ast.NodeId
# union syntax remains available even when structural checking erases nominal identity.
source ast.NodeId = ast.NodeId(maxval!(u32))
fields []RecordField
is_union bool = false
tag_type Type = Type{}
@@ -1291,6 +1293,14 @@ collect_expression proc(state @mut State, id ast.NodeId) void ! (mem.AllocError
try depend(state, value.type, id)
try depend(state, value.value, id)
}
.union_constant: {
value :: ast.union_constant(tree, id)
state.cells[usize(id)].fixed = true
state.cells[usize(id)].type = try check_annotation(state, value.type, false)
try collect_expression(state, value.value)
try depend(state, value.type, id)
try depend(state, value.value, id)
}
.try_expr: {
operand :: ast.type_operand(tree, id)
try collect_expression(state, operand)
@@ -2156,6 +2166,18 @@ validate proc(state @mut State, id ast.NodeId) void ! (mem.AllocError | diagnost
value :: ast.typed_constant(tree, id)
try compatible(state, value.value, state.cells[usize(id)].type)
}
.union_constant: {
value :: ast.union_constant(tree, id)
type :: state.cells[usize(id)].type
if (type.kind == .invalid) return
if (type.kind != .record or ast.expr_kind(tree, value.value) != .literal_string) {
try fail(state, id, .invalid_operand, ast.node_span(tree, id))
return
}
descriptor :: state.records.items[usize(type.record_type)]
if (!descriptor.is_union or descriptor.tag_type.kind != .none)
try fail(state, id, .invalid_operand, ast.node_span(tree, id))
}
.try_expr, .catch_expr: try validate_fallible_expression(state, id)
.unary: {
expression :: ast.unary_expr(tree, id)
@@ -4195,7 +4217,7 @@ check_union proc(state @mut State, id ast.NodeId, nominal bool) Type ! (mem.Allo
empty :: try mem.alloc(RecordField, state.records.allocator, 0)
declaration :: if (nominal) id else ast.NodeId(maxval!(u32))
arraylist.append(&state.records, RecordType{
declaration = declaration, fields = empty, is_union = true,
declaration = declaration, source = id, fields = empty, is_union = true,
}) catch |err| {
mem.free(state.records.allocator, empty)
return err
+5
View File
@@ -546,6 +546,11 @@ resolve_expr proc(
try resolve_expr(state, scope, value.type)
try resolve_expr(state, scope, value.value)
}
.union_constant: {
value :: ast.union_constant(tree, id)
try resolve_expr(state, scope, value.type)
try resolve_expr(state, scope, value.value)
}
.literal_int, .literal_float, .literal_bool, .literal_string, .literal_null, .literal_void,
.literal_undefined: {}
.enum_literal: {}
+59 -10
View File
@@ -1706,7 +1706,8 @@ branch_constant proc(state @State, id ast.NodeId, depth usize) bool {
)
}
match ast.expr_kind(&state.tree, id) {
.literal_int, .literal_float, .literal_bool, .literal_string, .literal_null, .literal_void: return true
.literal_int, .literal_float, .literal_bool, .literal_string, .literal_null, .literal_void,
.union_constant: return true
.typed_constant: return branch_constant(state, ast.typed_constant(&state.tree, id).value, depth + 1)
.enum_literal: return true
.record_literal: {
@@ -2332,6 +2333,32 @@ evaluate_expected proc(state @mut State, id, annotation ast.NodeId) Value ! Erro
return try evaluate(state, id)
}
# raw union snapshots cannot distinguish addresses from overlapping integer bits.
# require every view to be address-free before retaining the complete representation.
@hide:file
union_bytes_materializable proc(checked @typecheck.Result, type typecheck.Type, depth usize) bool {
if (depth >= 128) return false
match type.kind {
.int, .i8, .i16, .i32, .isize, .u8, .u16, .u32, .u64, .usize,
.float, .bool, .void, .enumeration: return true
.array, .optional: {
return union_bytes_materializable(checked, typecheck.array_type(checked, type.array_type).element, depth + 1)
}
.fallible: {
channels :: typecheck.array_type(checked, type.array_type)
return (union_bytes_materializable(checked, channels.element, depth + 1)
and union_bytes_materializable(checked, channels.error_type, depth + 1))
}
.record: {
for (typecheck.record_type(checked, type.record_type).fields) |field| {
if (!union_bytes_materializable(checked, field.type, depth + 1)) return false
}
return true
}
else: return false
}
}
# values leaving temporary evaluation must own no interpreter addresses or module IDs.
# snapshot readonly bytes and rebuild source literals/references before releasing their backing.
@hide:file
@@ -2417,8 +2444,25 @@ serialize_value proc(
result = try ast.add_array_literal(state.builder, span, elements.items)
} else if (type.kind == .record) {
record :: typecheck.record_type(checked, type.record_type)
# untagged storage has no active member; choosing one could discard bytes.
if (record.is_union and record.tag_type.kind != .enumeration) return no_node
if (record.is_union and record.tag_type.kind != .enumeration) {
if (!union_bytes_materializable(checked, type, 0)) return no_node
bytes :: try mem.alloc(u8, state.allocator, value.data.elements.len)
defer mem.free(state.allocator, bytes)
for (value.data.elements) |byte, index| {
if (byte.uninitialized) {
state.evaluation_diagnostic = try diagnostics.add(state.diagnostics, .read_before_initialization, span)
return no_node
}
bytes[index] = u8(byte.data.unsigned)
}
annotation :: if (type.nominal != no_node) type.nominal
else if (record.declaration != no_node) record.declaration else record.source
concrete :: try materialize(state, Value{ kind = .type, node = annotation })
literal :: try ast.add_string(state.builder, span, bytes)
result = try ast.add_union_constant(state.builder, span, concrete.node, literal)
try refresh(state)
return result
}
fields := arraylist.init(ast.NodeId, state.allocator)
defer arraylist.deinit(&fields)
for (record.fields) |field, i| {
@@ -2513,9 +2557,9 @@ constant_equal proc(state @mut State, left, right ast.NodeId) bool ! Error {
kind :: ast.expr_kind(&state.tree, left)
if (kind != ast.expr_kind(&state.tree, right)) return false
if (kind == .literal_void) return true
if (kind == .typed_constant) {
a :: ast.typed_constant(&state.tree, left)
b :: ast.typed_constant(&state.tree, right)
if (kind == .typed_constant or kind == .union_constant) {
a :: if (kind == .union_constant) ast.union_constant(&state.tree, left) else ast.typed_constant(&state.tree, left)
b :: if (kind == .union_constant) ast.union_constant(&state.tree, right) else ast.typed_constant(&state.tree, right)
return try same_type_context(state, a.type, b.type, true) and try constant_equal(state, a.value, b.value)
}
if (kind == .slice) {
@@ -2561,8 +2605,8 @@ constant_hash proc(state @mut State, id ast.NodeId) usize ! Error {
kind :: ast.expr_kind(&state.tree, id)
hash :: enum_index(kind)
if (kind == .literal_void) return hash
if (kind == .typed_constant) {
value :: ast.typed_constant(&state.tree, id)
if (kind == .typed_constant or kind == .union_constant) {
value :: if (kind == .union_constant) ast.union_constant(&state.tree, id) else ast.typed_constant(&state.tree, id)
return mix(mix(hash, try type_hash_context(state, value.type, true)), try constant_hash(state, value.value))
}
if (kind == .slice) {
@@ -4803,6 +4847,10 @@ evaluate proc(state @mut State, id ast.NodeId) Value ! Error {
return Value{}
}
match kind {
.union_constant: {
value :: ast.union_constant(&state.tree, id)
return Value{ kind = .aggregate, node = id, annotation = value.type }
}
.typed_constant: {
view :: ast.typed_constant(&state.tree, id)
if (!is_type(state, view.type) and type_record_storage(state, view.type, 0)) {
@@ -5287,8 +5335,9 @@ expression_type proc(state @mut State, id ast.NodeId, depth usize) ast.NodeId !
.record_type, .array_type, .slice_type, .pointer_type, .function_type, .optional_type,
.enum_type, .union_type, .union_tag_type, .distinct_type, .fallible_type, .sum_type, .alias_expr:
return try builtin(state, id, .type)
.typed_constant: {
annotation :: ast.typed_constant(&state.tree, id).type
.typed_constant, .union_constant: {
annotation :: if (kind == .union_constant) ast.union_constant(&state.tree, id).type
else ast.typed_constant(&state.tree, id).type
return if (is_type(state, annotation)) annotation else try canonical_type(state, annotation)
}
.literal_int: return try builtin(state, id, .i64)