refactor(ir): extract CoercionResolver (conversions.zig) for coercion planning (A4.3 step 2)

Coercion classification now lives in src/ir/conversions.zig behind a *Lowering
facade (CoercionResolver), mirroring CallResolver / GenericResolver /
ProtocolResolver. Two pure classifiers:
- classify(src, dst) -> CoercionPlan (15 kinds: no_op / unbox_any / box_any /
  closure_to_fn_reject / tuple_elementwise / optional_unwrap / void_to_optional /
  optional_wrap / erase_protocol / int_to_float / float_to_int / ptr_int_bitcast /
  widen / narrow / none) — the built-in coercion ladder.
- classifyXX(src, dst) -> XXPlan (unbox_any / no_op / erase_protocol /
  protocol_to_pointer / coerce) — the xx-operator head.

coerceToType and lowerXX now `switch (classify…)` then emit; branch order
mirrors the originals exactly and every arm reproduces the prior lowering — the
f32/f64 Any match dispatch, buildProtocolErasure (lowerXX) vs buildProtocolValue
(coerceToType), tuple/optional recursion, and the user-Into fallback + pointer
materialization + recursion-guard/diagnostics (which stay in lowerXX /
tryUserConversion). IR emission stays entirely in Lowering; the classifiers are
pure. lowerXX keeps the operand's lowered Ref type as src_ty. `.none` means no
built-in applies (pass through; the Into fallback runs) — no silent default.

New pub: isFloat / isIntEx / typeBitsEx / resolveConcreteTypeName (the classifier
reads them); coercionResolver() accessor. lower.zig net -54 lines.

conversions.test.zig drives CoercionResolver directly: the full classify ladder
(no-op, Any box/unbox, widen/narrow, int<->float, ptr<->int, optional
wrap/unwrap, void->optional, tuple, closure-reject, .none for two unrelated
structs), erase_protocol for a concrete source, and classifyXX (all 5 kinds incl.
protocol-to-pointer vs coerce and pointer-materialization -> coerce).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir churn.
This commit is contained in:
agra
2026-06-02 22:45:56 +03:00
parent 50dd2cc3d8
commit f3bda369f6
4 changed files with 377 additions and 175 deletions

View File

@@ -26,6 +26,7 @@ const ExprTyper = @import("expr_typer.zig").ExprTyper;
const CallResolver = @import("calls.zig").CallResolver;
const GenericResolver = @import("generics.zig").GenericResolver;
const ProtocolResolver = @import("protocols.zig").ProtocolResolver;
const CoercionResolver = @import("conversions.zig").CoercionResolver;
const semantic_diagnostics = @import("semantic_diagnostics.zig");
const TypeId = types.TypeId;
@@ -13796,7 +13797,7 @@ pub const Lowering = struct {
/// Resolve the concrete type name for protocol erasure.
/// Handles both direct types and pointer-to-types.
fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 {
if (ty.isBuiltin()) {
// Primitive types like s64 — check if they have toName()
return self.module.types.typeName(ty);
@@ -13840,6 +13841,10 @@ pub const Lowering = struct {
return .{ .l = self };
}
pub fn coercionResolver(self: *Lowering) CoercionResolver {
return .{ .l = self };
}
/// Lower the `xx` operator (type coercion).
/// Uses self.target_type for context when available. Handles:
/// - Any → concrete type: unbox_any
@@ -13855,59 +13860,54 @@ pub const Lowering = struct {
const target_explicit = self.target_type != null;
const dst_ty = self.target_type orelse .unresolved;
// Any → concrete type: unbox
if (src_ty == .any) {
// When inside a float match arm covering both f32 and f64,
// and target is f64, we need a mini-dispatch to unbox correctly.
// f32 values are stored as zext(bitcast(f32→i32), i64) in Any,
// so bitcasting i64→f64 directly gives wrong results for f32.
if (dst_ty == .f64) {
if (self.current_match_tags) |tags| {
var has_f32 = false;
var has_f64 = false;
for (tags) |t| {
const tid = TypeId.fromIndex(@intCast(t));
if (tid == .f32) has_f32 = true;
if (tid == .f64) has_f64 = true;
}
if (has_f32 and has_f64) {
return self.lowerAnyToF64Dispatch(operand);
}
if (has_f32 and !has_f64) {
// Only f32 values: unbox as f32, then widen
const f32_val = self.builder.emit(.{ .unbox_any = .{
.operand = operand,
} }, .f32);
return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
// PLANNING: the `xx`-head decision (conversions.zig). `.coerce` falls
// through to the built-in ladder + the user-`Into` fallback below.
switch (self.coercionResolver().classifyXX(src_ty, dst_ty)) {
// Any → concrete type: unbox.
.unbox_any => {
// When inside a float match arm covering both f32 and f64,
// and target is f64, we need a mini-dispatch to unbox correctly.
// f32 values are stored as zext(bitcast(f32→i32), i64) in Any,
// so bitcasting i64→f64 directly gives wrong results for f32.
if (dst_ty == .f64) {
if (self.current_match_tags) |tags| {
var has_f32 = false;
var has_f64 = false;
for (tags) |t| {
const tid = TypeId.fromIndex(@intCast(t));
if (tid == .f32) has_f32 = true;
if (tid == .f64) has_f64 = true;
}
if (has_f32 and has_f64) {
return self.lowerAnyToF64Dispatch(operand);
}
if (has_f32 and !has_f64) {
// Only f32 values: unbox as f32, then widen
const f32_val = self.builder.emit(.{ .unbox_any = .{
.operand = operand,
} }, .f32);
return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64);
}
}
}
}
return self.builder.emit(.{ .unbox_any = .{
.operand = operand,
} }, dst_ty);
}
// Same type: no-op
if (src_ty == dst_ty) return operand;
// Concrete → Protocol: build protocol value
if (self.getProtocolInfo(dst_ty)) |_| {
return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty);
}
// Protocol → pointer: recover the typed ctx pointer (field 0).
// The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
// `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
if (self.getProtocolInfo(src_ty)) |_| {
if (!dst_ty.isBuiltin()) {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .pointer) {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
if (dst_ty == void_ptr_ty) return ctx_ref;
return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
}
}
return self.builder.emit(.{ .unbox_any = .{
.operand = operand,
} }, dst_ty);
},
// Same type: no-op.
.no_op => return operand,
// Concrete → Protocol: build protocol value.
.erase_protocol => return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty),
// Protocol → pointer: recover the typed ctx pointer (field 0).
// The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
// `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
.protocol_to_pointer => {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
if (dst_ty == void_ptr_ty) return ctx_ref;
return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
},
.coerce => {},
}
const result = self.coerceToType(operand, src_ty, dst_ty);
@@ -14468,42 +14468,35 @@ pub const Lowering = struct {
/// Insert a conversion if src_ty and dst_ty differ.
/// Handles int widening/narrowing, float widening/narrowing, and int↔float.
fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref {
if (src_ty == dst_ty) return val;
// Unbox Any → concrete type
if (src_ty == .any and dst_ty != .any) {
return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty);
}
// Box concrete → Any
if (dst_ty == .any and src_ty != .any) {
return self.builder.boxAny(val, src_ty);
}
// Closure VALUE → bare function-pointer slot: not soundly representable.
// A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env
// arg, but a closure's underlying fn takes an env slot — so passing a
// closure value's fn_ptr drops the env and shifts the args (UB for a
// matching ABI, a wrong-tuple read for ∅-widening, a segfault when the
// closure captures). Only a closure LITERAL can cross this boundary,
// via the static adapter `lowerLambda` emits (so a literal arrives here
// already typed `.function`). Reject the variable case loudly.
if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) {
if (self.module.types.get(src_ty) == .closure and self.module.types.get(dst_ty) == .function) {
// PLANNING: classify the built-in coercion (conversions.zig).
// EMISSION: each arm below reproduces the original lowering.
switch (self.coercionResolver().classify(src_ty, dst_ty)) {
.no_op, .none => return val,
// Unbox Any → concrete type
.unbox_any => return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty),
// Box concrete → Any
.box_any => return self.builder.boxAny(val, src_ty),
// Closure VALUE → bare function-pointer slot: not soundly representable.
// A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env
// arg, but a closure's underlying fn takes an env slot — so passing a
// closure value's fn_ptr drops the env and shifts the args (UB for a
// matching ABI, a wrong-tuple read for ∅-widening, a segfault when the
// closure captures). Only a closure LITERAL can cross this boundary,
// via the static adapter `lowerLambda` emits (so a literal arrives here
// already typed `.function`). Reject the variable case loudly.
.closure_to_fn_reject => {
if (self.diagnostics) |d| {
const cs = self.builder.current_span;
d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a closure value cannot be passed as a bare function-pointer `(...) -> ...` — its environment can't be carried across the bare ABI; pass the closure literal directly at the call site, or declare the parameter type as `Closure(...)`", .{});
}
return val;
}
}
// Tuple → Tuple element-wise coercion (e.g. a `(s64, s64)` literal
// flowing into a `(s32, s32)` slot — the multi-value failable success
// tuple). Same arity, at least one differing field (src_ty == dst_ty
// already returned above): extract each slot, coerce it, rebuild.
if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) {
const si = self.module.types.get(src_ty);
const di = self.module.types.get(dst_ty);
if (si == .tuple and di == .tuple and si.tuple.fields.len == di.tuple.fields.len) {
},
// Tuple → Tuple element-wise coercion (e.g. a `(s64, s64)` literal
// flowing into a `(s32, s32)` slot — the multi-value failable success
// tuple). Same arity: extract each slot, coerce it, rebuild.
.tuple_elementwise => {
const si = self.module.types.get(src_ty);
const di = self.module.types.get(dst_ty);
var elems = std.ArrayList(Ref).empty;
defer elems.deinit(self.alloc);
for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| {
@@ -14511,100 +14504,53 @@ pub const Lowering = struct {
elems.append(self.alloc, self.coerceToType(fv, sf, df)) catch unreachable;
}
return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty);
}
}
// Optional → Concrete unwrapping (flow-sensitive narrowing coercion)
if (!src_ty.isBuiltin()) {
const src_info = self.module.types.get(src_ty);
if (src_info == .optional) {
const child_ty = src_info.optional.child;
if (child_ty == dst_ty or (dst_ty.isBuiltin() and child_ty.isBuiltin())) {
const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
return self.coerceToType(unwrapped, child_ty, dst_ty);
}
}
}
// void → Optional: produce null (void is the type of null_literal)
if (src_ty == .void and !dst_ty.isBuiltin()) {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .optional) {
return self.builder.constNull(dst_ty);
}
}
// Concrete → Optional wrapping
if (!dst_ty.isBuiltin()) {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .optional) {
const child_ty = dst_info.optional.child;
// Coerce the value to the inner type first
},
// Optional → Concrete unwrapping (flow-sensitive narrowing coercion)
.optional_unwrap => {
const child_ty = self.module.types.get(src_ty).optional.child;
const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty);
return self.coerceToType(unwrapped, child_ty, dst_ty);
},
// void → Optional: produce null (void is the type of null_literal)
.void_to_optional => return self.builder.constNull(dst_ty),
// Concrete → Optional wrapping (coerce to the inner type first)
.optional_wrap => {
const child_ty = self.module.types.get(dst_ty).optional.child;
const coerced = self.coerceToType(val, src_ty, child_ty);
return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty);
}
}
// Concrete → Protocol (auto type erasure)
if (self.getProtocolInfo(dst_ty)) |_| {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .@"struct") {
const proto_name = self.module.types.getString(dst_info.@"struct".name);
if (self.resolveConcreteTypeName(src_ty)) |ctn| {
// If src is a pointer, use directly; otherwise alloca+store + heap-copy
var concrete_ptr = val;
var concrete_ty = src_ty;
var heap_copy = false;
if (!src_ty.isBuiltin()) {
const si = self.module.types.get(src_ty);
if (si == .pointer) {
concrete_ty = si.pointer.pointee;
heap_copy = false;
} else {
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, val);
concrete_ptr = slot;
heap_copy = true;
}
},
// Concrete → Protocol (auto type erasure)
.erase_protocol => {
const proto_name = self.module.types.getString(self.module.types.get(dst_ty).@"struct".name);
const ctn = self.resolveConcreteTypeName(src_ty).?;
// If src is a pointer, use directly; otherwise alloca+store + heap-copy
var concrete_ptr = val;
var concrete_ty = src_ty;
var heap_copy = false;
if (!src_ty.isBuiltin()) {
const si = self.module.types.get(src_ty);
if (si == .pointer) {
concrete_ty = si.pointer.pointee;
heap_copy = false;
} else {
const slot = self.builder.alloca(src_ty);
self.builder.store(slot, val);
concrete_ptr = slot;
heap_copy = true;
}
return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
}
}
return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy);
},
.int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.float_to_int => return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
// Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot.
// Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches
// to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level
// since LLVMBuildBitCast itself doesn't accept ptr↔int.
.ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
.widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty),
}
const src_float = isFloat(src_ty);
const dst_float = isFloat(dst_ty);
const src_int = self.isIntEx(src_ty);
const dst_int = self.isIntEx(dst_ty);
const src_ptr = !src_ty.isBuiltin() and self.module.types.get(src_ty) == .pointer;
const dst_ptr = !dst_ty.isBuiltin() and self.module.types.get(dst_ty) == .pointer;
// Int → Float
if (src_int and dst_float) {
return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
}
// Float → Int
if (src_float and dst_int) {
return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
}
// Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot.
// Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches
// to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level
// since LLVMBuildBitCast itself doesn't accept ptr↔int.
if ((src_ptr and dst_int) or (src_int and dst_ptr)) {
return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
}
// Same kind — widen/narrow based on bit width
const src_bits = self.typeBitsEx(src_ty);
const dst_bits = self.typeBitsEx(dst_ty);
if (src_bits > 0 and dst_bits > 0) {
if (dst_bits < src_bits) {
return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
} else if (dst_bits > src_bits) {
return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);
}
}
return val;
}
/// Get the alloca Ref for an expression, if it's a simple variable reference.
@@ -14639,7 +14585,7 @@ pub const Lowering = struct {
};
}
fn isFloat(ty: TypeId) bool {
pub fn isFloat(ty: TypeId) bool {
return ty == .f32 or ty == .f64;
}
@@ -14650,7 +14596,7 @@ pub const Lowering = struct {
};
}
fn isIntEx(self: *Lowering, ty: TypeId) bool {
pub fn isIntEx(self: *Lowering, ty: TypeId) bool {
if (isInt(ty)) return true;
if (!ty.isBuiltin()) {
const info = self.module.types.get(ty);
@@ -15984,7 +15930,7 @@ pub const Lowering = struct {
};
}
fn typeBitsEx(self: *Lowering, ty: TypeId) u32 {
pub fn typeBitsEx(self: *Lowering, ty: TypeId) u32 {
if (ty == .usize or ty == .isize) return @as(u32, self.module.types.pointer_size) * 8;
const b = typeBits(ty);
if (b > 0) return b;