ffi M5.A.next.1d.B: pack impl matching — bind $args + $R per call

Pack-shaped impls (`impl P(...) for Closure(..$args) -> $R`) now
match concrete closure sources at xx resolution time. Concrete
impls keep their priority — pack matching only fires on a
concrete-key miss in `param_impl_map`.

New plumbing in src/ir/lower.zig:

- `PackParamImplEntry` carries the pack-shaped source TypeId plus
  the pack-var and ret-var names extracted from the impl AST's
  `target_type_expr`. `registerParamImpl` detects pack-shaped
  sources via `pack_start != null` on the resolved closure type
  and additionally registers in a new `param_impl_pack_map`
  keyed by `"Proto\x00<arg_mangled>"` (no source suffix).

- `tryUserConversion` re-shapes the concrete lookup so the pack
  path runs on miss. `tryPackImplMatch` walks the pack entries,
  verifies the source's fixed prefix matches the impl's prefix,
  binds the pack-var to the source's tail param TypeIds, binds
  the ret-var (when the impl's return is generic) to the source
  return, and monomorphises the convert method. Mangled name
  stays keyed on the concrete source so distinct call shapes
  monomorphise separately.

- `pack_bindings: ?StringHashMap([]const TypeId)` is saved/
  restored around monomorphisation, mirroring `type_bindings`.

- `resolveClosureTypeWithBindings` handles the closure_type_expr
  node during type resolution: when the closure carries a
  `pack_name` AND `pack_bindings` has a binding for it, the
  bound TypeIds are appended after the fixed prefix and the
  result is a concrete (non-pack) closure type — so the impl
  body's `self: Closure(..$args) -> $R` substitutes to the
  concrete source closure during monomorphisation. Without an
  active binding, the pack shape is preserved.

`examples/155-pack-impl-match.sx` flips from the
"no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to
"pack impl match ok": one user-declared
`impl Into(Block) for Closure(..$args) -> $R` covers a
`Closure(s32, bool) -> bool` source that stdlib has no
hand-rolled impl for. Constructed Block isn't invoked
(invoke=null) — the test exercises only the matching +
monomorphisation, not the trampoline (step 5 of the plan).

Existing concrete-impl paths unchanged: 95-objc-block-noop,
96-objc-block-multi-arg, and stdlib's hand-rolled
`Into(Block) for Closure(bool) -> void` continue to pass through
the concrete map first. Same-file duplicate pack impls
diagnose at registration; cross-module visibility and
multi-pack-impl specificity stay TODOs (matching the deferred
Phase 5 work on the concrete path).

193/193 example tests + `zig build test` green.
This commit is contained in:
agra
2026-05-27 12:57:45 +03:00
parent ce3c2fe7bd
commit 08feb6040b
3 changed files with 257 additions and 20 deletions

View File

@@ -136,6 +136,16 @@ pub const Lowering = struct {
protocol_vtable_type_map: std.StringHashMap(TypeId) = std.StringHashMap(TypeId).init(std.heap.page_allocator), // protocol name → vtable struct TypeId
protocol_vtable_global_map: std.StringHashMap(inst_mod.GlobalId) = std.StringHashMap(inst_mod.GlobalId).init(std.heap.page_allocator), // "Proto\x00Type" → vtable GlobalId
param_impl_map: std.StringHashMap(std.ArrayList(ParamImplEntry)) = std.StringHashMap(std.ArrayList(ParamImplEntry)).init(std.heap.page_allocator), // "Proto\x00<arg_mangled>\x00<src_mangled>" → impl entries (parameterised protocols only; list lets Phase 4/5 detect cross-module overlap)
/// Pack-variadic impl entries — separate map keyed by `"Proto\x00<arg_mangled>"`
/// (NO source suffix) so a single impl `Closure(..$args) -> $R` can be
/// matched against many concrete source shapes. Concrete impls in
/// `param_impl_map` win when both match (specificity rule).
param_impl_pack_map: std.StringHashMap(std.ArrayList(PackParamImplEntry)) = std.StringHashMap(std.ArrayList(PackParamImplEntry)).init(std.heap.page_allocator),
/// Active pack bindings during monomorphisation. Mirrors `type_bindings`
/// but for variadic pack names: `args → [T1, T2, ...]`. Read by
/// `resolveTypeWithBindings` on closure_type_expr to substitute
/// `Closure(..$args) -> $R` into a concrete closure type.
pack_bindings: ?std.StringHashMap([]const TypeId) = null,
struct_const_map: std.StringHashMap(StructConstInfo) = std.StringHashMap(StructConstInfo).init(std.heap.page_allocator), // "Struct.CONST" → value info
module_const_map: std.StringHashMap(ModuleConstInfo) = std.StringHashMap(ModuleConstInfo).init(std.heap.page_allocator), // module-level value constants (e.g. AF_INET :s32: 2)
foreign_name_map: std.StringHashMap([]const u8) = std.StringHashMap([]const u8).init(std.heap.page_allocator), // sx name → C name for #foreign renames
@@ -188,6 +198,22 @@ pub const Lowering = struct {
span: ast.Span,
};
/// Pack-variadic impl entry — `impl Proto(Args...) for Closure(Prefix..., ..$pack) -> $ret`.
/// Matches any concrete closure source whose first `prefix_len` param types
/// equal `source_pack_ty`'s fixed prefix; the tail binds to `pack_var_name`
/// (e.g. "args") and the source's return type binds to `ret_var_name`
/// (e.g. "R") when the impl's return is generic. `ret_var_name == null`
/// means the return type is concrete and must match exactly.
const PackParamImplEntry = struct {
methods: []const *const ast.FnDecl,
source_pack_ty: TypeId,
target_args: []const TypeId,
defining_module: []const u8,
span: ast.Span,
pack_var_name: []const u8,
ret_var_name: ?[]const u8,
};
/// Owned copy of a generic struct template (AST pointers are copied/interned to survive imports)
const StructTemplate = struct {
name: []const u8,
@@ -9043,6 +9069,9 @@ pub const Lowering = struct {
// Handle List(T), Vector(N, T) etc. as type constructor calls
return self.resolveTypeCallWithBindings(&cl);
},
.closure_type_expr => |ct| {
return self.resolveClosureTypeWithBindings(&ct);
},
else => {},
}
}
@@ -9077,6 +9106,9 @@ pub const Lowering = struct {
const len: u32 = if (at.length.data == .int_literal) @intCast(at.length.data.int_literal.value) else 0;
return self.module.types.arrayOf(elem, len);
},
.closure_type_expr => |ct| {
return self.resolveClosureTypeWithBindings(&ct);
},
else => {},
}
// Check type aliases before falling through to type_bridge
@@ -9086,6 +9118,36 @@ pub const Lowering = struct {
return type_bridge.resolveAstType(node, &self.module.types);
}
/// Resolve a `Closure(...)` type expression with the active type/pack
/// bindings applied. Pack-shaped closure exprs (`Closure(Prefix..., ..$pack)`)
/// substitute `pack` from `self.pack_bindings`, producing a concrete
/// closure type — used when monomorphising a pack-variadic impl body
/// against a concrete source signature.
fn resolveClosureTypeWithBindings(self: *Lowering, ct: *const ast.ClosureTypeExpr) TypeId {
var param_ids = std.ArrayList(TypeId).empty;
defer param_ids.deinit(self.alloc);
for (ct.param_types) |pt| {
param_ids.append(self.alloc, self.resolveTypeWithBindings(pt)) catch return .void;
}
if (ct.pack_name) |pn| {
if (self.pack_bindings) |pb| {
if (pb.get(pn)) |pack_tys| {
for (pack_tys) |t| param_ids.append(self.alloc, t) catch return .void;
// Fully bound — emit a concrete closure type, no pack_start.
const ret_ty = if (ct.return_type) |rt| self.resolveTypeWithBindings(rt) else .void;
return self.module.types.closureType(param_ids.items, ret_ty);
}
}
// Pack name in scope but no binding — preserve the pack-shape
// so downstream code can still see it's variadic. (Hit during
// impl-block parsing before any concrete monomorphisation.)
const ret_ty = if (ct.return_type) |rt| self.resolveTypeWithBindings(rt) else .void;
return self.module.types.closureTypePack(param_ids.items, ret_ty, @intCast(param_ids.items.len));
}
const ret_ty = if (ct.return_type) |rt| self.resolveTypeWithBindings(rt) else .void;
return self.module.types.closureType(param_ids.items, ret_ty);
}
/// Resolve a .call node that represents a type constructor (e.g., List(T), Vector(N, T)).
fn resolveTypeCallWithBindings(self: *Lowering, cl: *const ast.Call) TypeId {
const callee_name: []const u8 = switch (cl.callee.data) {
@@ -10050,6 +10112,11 @@ pub const Lowering = struct {
/// stashes the impl's method fn_decls for later monomorphisation by
/// `lowerXX`. Same-module duplicate impls produce a diagnostic here;
/// cross-module duplicates are detected at the xx resolution site.
///
/// Pack-shaped sources (`Closure(..$args) -> $R`, detected via
/// `pack_start != null`) are additionally registered into
/// `param_impl_pack_map` keyed without the source suffix — the matching
/// site walks that map to bind packs against any concrete closure shape.
fn registerParamImpl(self: *Lowering, ib: *const ast.ImplBlock, decl: *const Node) void {
const table = &self.module.types;
@@ -10077,6 +10144,7 @@ pub const Lowering = struct {
key_buf.append(self.alloc, 0) catch return;
key_buf.appendSlice(self.alloc, self.mangleTypeName(t)) catch return;
}
const pack_key_len = key_buf.items.len; // proto + args, no src — used for pack map
key_buf.append(self.alloc, 0) catch return;
key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return;
const key = key_buf.items;
@@ -10117,6 +10185,54 @@ pub const Lowering = struct {
}
}
gop.value_ptr.append(self.alloc, entry) catch return;
// Pack-shaped source: also register in the pack map. The source
// closure carries `pack_start` set; matching binds the source's
// tail param types to the pack-name and the source's return to
// the impl's return-type-var (when the return is generic).
const src_info = table.get(src_ty);
if (src_info == .closure and src_info.closure.pack_start != null) {
const target_expr_node = ib.target_type_expr orelse return;
if (target_expr_node.data != .closure_type_expr) return;
const ct = target_expr_node.data.closure_type_expr;
const pack_var = ct.pack_name orelse return;
// Extract the return-type-var name if the impl's return is generic.
// `Closure(...) -> $R` parses with the return-type node carrying
// `is_generic = true`. Concrete returns leave it null.
var ret_var: ?[]const u8 = null;
if (ct.return_type) |rt| {
if (rt.data == .type_expr and rt.data.type_expr.is_generic) {
ret_var = rt.data.type_expr.name;
}
}
const pack_entry: PackParamImplEntry = .{
.methods = self.alloc.dupe(*const ast.FnDecl, methods.items) catch return,
.source_pack_ty = src_ty,
.target_args = self.alloc.dupe(TypeId, arg_tys.items) catch return,
.defining_module = defining_module,
.span = decl.span,
.pack_var_name = self.alloc.dupe(u8, pack_var) catch return,
.ret_var_name = if (ret_var) |rv| (self.alloc.dupe(u8, rv) catch return) else null,
};
const pack_key = key_buf.items[0..pack_key_len];
const pack_key_owned = self.alloc.dupe(u8, pack_key) catch return;
const pgop = self.param_impl_pack_map.getOrPut(pack_key_owned) catch return;
if (!pgop.found_existing) {
pgop.value_ptr.* = std.ArrayList(PackParamImplEntry).empty;
} else {
for (pgop.value_ptr.items) |existing| {
if (std.mem.eql(u8, existing.defining_module, defining_module)) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, decl.span, "duplicate pack impl '{s}' for source '{s}' in {s}", .{
ib.protocol_name, self.mangleTypeName(src_ty), defining_module,
});
}
return;
}
}
}
pgop.value_ptr.append(self.alloc, pack_entry) catch return;
}
}
/// Synthesize a fn_decl from a protocol default method for a concrete type.
@@ -11069,6 +11185,133 @@ pub const Lowering = struct {
return dst_info.@"struct".name == block_name;
}
/// Pack-variadic impl matching. Walks `param_impl_pack_map[pack_key]`
/// and returns a call ref when a single pack impl matches `src_ty`'s
/// shape (concrete src closure / fn with the same fixed prefix as
/// the impl's source pack closure). Binds the pack-var to the source's
/// tail param types and the return-var (when generic) to the source's
/// return type, then monomorphises the convert method.
/// Returns null if no pack impls registered for this (proto, dst) or
/// none of them match `src_ty`'s shape.
fn tryPackImplMatch(
self: *Lowering,
operand: Ref,
operand_node: *const Node,
src_ty: TypeId,
dst_ty: TypeId,
proto_name: []const u8,
pack_key: []const u8,
guard_key: u64,
) ?Ref {
_ = operand_node;
const pack_entries = self.param_impl_pack_map.get(pack_key) orelse return null;
if (pack_entries.items.len == 0) return null;
const table = &self.module.types;
// Source must itself be a closure/function the pack can match.
const src_info = table.get(src_ty);
if (src_info != .closure and src_info != .function) return null;
const src_params: []const TypeId = switch (src_info) {
.closure => |c| c.params,
.function => |f| f.params,
else => unreachable,
};
const src_ret: TypeId = switch (src_info) {
.closure => |c| c.ret,
.function => |f| f.ret,
else => unreachable,
};
// Find pack impls whose fixed prefix matches src's leading params.
var matched_idx: ?usize = null;
for (pack_entries.items, 0..) |entry, i| {
const ent_info = table.get(entry.source_pack_ty);
// Pack impls always wear a closure (resolveClosureType routes
// both Closure and the future Fn pack forms through
// closureTypePack); a function-typed pack impl is not produced
// by current parser shapes.
if (ent_info != .closure) continue;
const ent_ci = ent_info.closure;
const pack_start = ent_ci.pack_start orelse continue;
// Fixed prefix must fit within the source's params.
if (pack_start > src_params.len) continue;
var prefix_ok = true;
var i_fix: u32 = 0;
while (i_fix < pack_start) : (i_fix += 1) {
if (ent_ci.params[i_fix] != src_params[i_fix]) {
prefix_ok = false;
break;
}
}
if (!prefix_ok) continue;
// Return type: if the impl's return is a generic var
// (ret_var_name set), any source return binds; otherwise it
// must equal the source's return exactly.
if (entry.ret_var_name == null and ent_ci.ret != src_ret) continue;
// First match wins for v1; concrete-wins-over-pack already
// happened by the caller checking concrete first. Multiple
// overlapping pack impls would be a separate diagnostic
// (deferred — same module duplicates are caught at registration).
matched_idx = i;
break;
}
const idx = matched_idx orelse return null;
const entry = pack_entries.items[idx];
// Find the `convert` method.
var convert_fd: ?*const ast.FnDecl = null;
for (entry.methods) |m| {
if (std.mem.eql(u8, m.name, "convert")) {
convert_fd = m;
break;
}
}
const fd = convert_fd orelse return null;
// Build bindings. Target → dst_ty (already in the protocol's type
// params), pack-var → src tail TypeIds, ret-var (when generic) →
// src ret.
const ent_pack_start = table.get(entry.source_pack_ty).closure.pack_start.?;
const tail = src_params[ent_pack_start..];
const tail_owned = self.alloc.dupe(TypeId, tail) catch return null;
var bindings = std.StringHashMap(TypeId).init(self.alloc);
defer bindings.deinit();
const pd = self.protocol_ast_map.get(proto_name) orelse return null;
bindings.put(pd.type_params[0].name, dst_ty) catch return null;
if (entry.ret_var_name) |rv| bindings.put(rv, src_ret) catch return null;
var pack_bindings = std.StringHashMap([]const TypeId).init(self.alloc);
defer pack_bindings.deinit();
pack_bindings.put(entry.pack_var_name, tail_owned) catch return null;
// Mangled name keyed on the CONCRETE source so distinct shapes
// monomorphise separately. Same scheme as the concrete path:
// "<src>.convert__<dst>".
const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{
self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty),
}) catch return null;
self.xx_reentrancy.put(guard_key, {}) catch {};
defer _ = self.xx_reentrancy.remove(guard_key);
if (!self.lowered_functions.contains(mangled)) {
const saved_pack = self.pack_bindings;
self.pack_bindings = pack_bindings;
defer self.pack_bindings = saved_pack;
self.monomorphizeFunction(fd, mangled, &bindings);
}
const fid = self.resolveFuncByName(mangled) orelse return null;
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
var single = [_]Ref{operand};
const final_args = self.prependCtxIfNeeded(func, single[0..]);
self.coerceCallArgs(final_args, params);
return self.builder.call(fid, final_args, ret_ty);
}
/// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise
/// the impl's `convert` method and emit a direct call. Returns null when
/// no impl matches (caller falls back to the built-in result, which is
@@ -11101,25 +11344,18 @@ pub const Lowering = struct {
key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null;
const key = key_buf.items;
const entries = self.param_impl_map.get(key) orelse {
// M5.A — focused diagnostic for the closure→Block case. When
// a user writes `xx cl : Block` for a closure signature that
// has no `Into(Block) for Closure(<sig>)` impl in stdlib or
// user code, the generic "no Into impl" path returns silently
// and the cast becomes a no-op. Emit a hint pointing at the
// missing impl pattern so they know what to add.
if (self.isClosureToBlockCast(src_ty, dst_ty)) {
if (self.diagnostics) |diags| {
const saved = diags.current_source_file;
diags.current_source_file = operand_node.source_file orelse self.current_source_file;
defer diags.current_source_file = saved;
diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)});
}
return operand;
// Pack-only key (proto + dst) — used if the concrete lookup misses.
// Same prefix as the concrete key, minus the `\x00<src_mangled>` tail.
const dst_mangled_len = self.mangleTypeName(dst_ty).len;
const pack_key = key_buf.items[0 .. proto_name.len + 1 + dst_mangled_len];
const entries_opt = self.param_impl_map.get(key);
const has_concrete = entries_opt != null and entries_opt.?.items.len > 0;
if (!has_concrete) {
// Concrete miss — try the pack map before emitting a diagnostic.
if (self.tryPackImplMatch(operand, operand_node, src_ty, dst_ty, proto_name, pack_key, guard_key)) |result| {
return result;
}
return null;
};
if (entries.items.len == 0) {
if (self.isClosureToBlockCast(src_ty, dst_ty)) {
if (self.diagnostics) |diags| {
const saved = diags.current_source_file;
@@ -11131,6 +11367,7 @@ pub const Lowering = struct {
}
return null;
}
const entries = entries_opt.?;
// Filter by import visibility: only impls in modules that the current
// file transitively imports (or the current file itself) are reachable.

View File

@@ -1 +1 @@
1
0

View File

@@ -1 +1 @@
/Users/agra/projects/sx/examples/155-pack-impl-match.sx:43:21: error: no `Into(Block) for cl_s32_bool__bool` impl — add a per-signature `__block_invoke_<sig>` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code
pack impl match ok