refactor(ir): extract protocol/impl lookup into protocols.zig (A4.2 step 2)

Move the pure protocol/impl conformance lookups into one module,
src/ir/protocols.zig, behind a *Lowering facade (ProtocolResolver), mirroring
GenericResolver / CallResolver. Per PLAN-ARCH A4.2 ("move pure lookup first;
keep emission in Lowering"), this increment moves only the read-only queries:
- getProtocolInfo (is a type a registered protocol + its method table),
- hasImplPlain (have the (protocol, type) thunks been materialized),
- packArgConformsTo (impl-declaration-level conformance for ..xs: P).

Registration (registerProtocolDecl / registerImplBlock / registerParamImpl) and
all IR emission (createProtocolThunk / buildProtocolValue / tryUserConversion /
getOrCreateThunks) stay in Lowering for the later increments. The state maps
(protocol_thunk_map / param_impl_map on Lowering, protocol_decl_map /
protocol_ast_map in ProgramIndex) stay put; the facade reads them via self.l.* —
no map migration.

Lowering keeps getProtocolInfo as a thin pub wrapper (~9 callers incl.
calls.zig); hasImplPlain + packArgConformsTo are deleted (no fallback), their 3
call sites (computeHasImpl x2, the pack-conformance check x1) routed through
self.protocolResolver(). formatTypeName widened to pub (the lookups use it);
protocolResolver() accessor added.

protocols.test.zig (wired into the barrel) drives ProtocolResolver directly:
getProtocolInfo (registered vs builtin/plain-struct + wrapper delegation),
hasImplPlain (thunk-map materialization), packArgConformsTo (non-parameterised
requires <ty>.<m> in fn_ast_map; trivially-true for an erased protocol value;
false for unknown protocol).

zig build, zig build test, tests/run_examples.sh (357/0) all green — no .ir
snapshot churn; the 0410/0411/0412 rejection anchors still pass.
This commit is contained in:
agra
2026-06-02 21:56:03 +03:00
parent df386a422e
commit 81d332dfb0
4 changed files with 208 additions and 55 deletions

View File

@@ -10,6 +10,7 @@ pub const packs = @import("packs.zig");
pub const expr_typer = @import("expr_typer.zig");
pub const calls = @import("calls.zig");
pub const generics = @import("generics.zig");
pub const protocols = @import("protocols.zig");
pub const semantic_diagnostics = @import("semantic_diagnostics.zig");
pub const TypeId = types.TypeId;
@@ -45,6 +46,7 @@ pub const ExprTyper = expr_typer.ExprTyper;
pub const CallResolver = calls.CallResolver;
pub const CallPlan = calls.CallPlan;
pub const GenericResolver = generics.GenericResolver;
pub const ProtocolResolver = protocols.ProtocolResolver;
pub const compiler_hooks = @import("compiler_hooks.zig");
pub const emit_llvm = @import("emit_llvm.zig");
@@ -69,6 +71,7 @@ pub const packs_tests = @import("packs.test.zig");
pub const expr_typer_tests = @import("expr_typer.test.zig");
pub const calls_tests = @import("calls.test.zig");
pub const generics_tests = @import("generics.test.zig");
pub const protocols_tests = @import("protocols.test.zig");
pub const type_bridge_tests = @import("type_bridge.test.zig");
pub const emit_llvm_tests = @import("emit_llvm.test.zig");
pub const jni_descriptor_tests = @import("jni_descriptor.test.zig");

View File

@@ -25,6 +25,7 @@ const PackResolver = @import("packs.zig").PackResolver;
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 semantic_diagnostics = @import("semantic_diagnostics.zig");
const TypeId = types.TypeId;
@@ -3860,8 +3861,8 @@ pub const Lowering = struct {
/// reports a diagnostic if it wants).
fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool {
switch (proto_node.data) {
.identifier => |id| return self.hasImplPlain(id.name, ty),
.type_expr => |te| return self.hasImplPlain(te.name, ty),
.identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty),
.type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty),
.call => |c| {
const p_name: []const u8 = switch (c.callee.data) {
.identifier => |id| id.name,
@@ -3888,52 +3889,6 @@ pub const Lowering = struct {
}
}
fn hasImplPlain(self: *Lowering, p_name: []const u8, ty: TypeId) bool {
const ty_name = self.formatTypeName(ty);
const thunk_key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ p_name, ty_name }) catch return false;
return self.protocol_thunk_map.contains(thunk_key);
}
/// Does `ty` conform to protocol `p_name` (under SOME type-args for a
/// parameterised protocol)? Used to check protocol-pack elements
/// (`..xs: P`), where each element's protocol type-args are inferred from
/// its impl rather than written out.
///
/// Conformance is queried at the IMPL-DECLARATION level (not via
/// `protocol_thunk_map`, which is only populated lazily when a protocol
/// VALUE is created with `xx`):
/// - Parameterised `P`: any `param_impl_map` key `P\x00<args>\x00<mangle(ty)>`.
/// - Non-parameterised `P`: every required (non-default) method `m` is
/// registered as `<ty>.<m>` in `fn_ast_map` (how `registerImplBlock`
/// records a non-parameterised impl).
/// An arg already of the protocol's own (erased) type trivially conforms.
fn packArgConformsTo(self: *Lowering, p_name: []const u8, ty: TypeId) bool {
// Arg already erased to the protocol struct itself (e.g. `xx a`).
if (!ty.isBuiltin()) {
const info = self.module.types.get(ty);
if (info == .@"struct" and info.@"struct".is_protocol and
std.mem.eql(u8, self.module.types.getString(info.@"struct".name), p_name)) return true;
}
const pd = self.program_index.protocol_ast_map.get(p_name) orelse return false;
if (pd.type_params.len > 0) {
const prefix = std.fmt.allocPrint(self.alloc, "{s}\x00", .{p_name}) catch return false;
const suffix = std.fmt.allocPrint(self.alloc, "\x00{s}", .{self.mangleTypeName(ty)}) catch return false;
var it = self.param_impl_map.keyIterator();
while (it.next()) |k| {
if (std.mem.startsWith(u8, k.*, prefix) and std.mem.endsWith(u8, k.*, suffix)) return true;
}
return false;
}
// Non-parameterised: require each non-default method as `<ty>.<m>`.
const ty_name = self.formatTypeName(ty);
for (pd.methods) |m| {
if (m.default_body != null) continue;
const q = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ ty_name, m.name }) catch return false;
if (!self.program_index.fn_ast_map.contains(q)) return false;
}
return true;
}
/// Evaluate a compile-time condition for `inline if`.
/// Handles: `ident == .variant`, `ident != .variant`, `ident == int`, `ident != int`.
fn evalComptimeCondition(self: *Lowering, node: *const Node) ?bool {
@@ -10378,7 +10333,7 @@ pub const Lowering = struct {
if (pack_protocol) |proto| {
if (self.program_index.protocol_ast_map.contains(proto)) {
for (call_node.args[pack_start..], pack_arg_types.items) |arg_node, arg_ty| {
if (!self.packArgConformsTo(proto, arg_ty)) {
if (!self.protocolResolver().packArgConformsTo(proto, arg_ty)) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, arg_node.span, "pack argument of type '{s}' does not conform to protocol '{s}'", .{ self.formatTypeName(arg_ty), proto });
}
@@ -11265,7 +11220,7 @@ pub const Lowering = struct {
}
/// Format a type name for display (e.g. "*Point", "[]s32", "[3]f64").
fn formatTypeName(self: *Lowering, ty: TypeId) []const u8 {
pub fn formatTypeName(self: *Lowering, ty: TypeId) []const u8 {
// Builtin types: use their canonical name
if (ty == .s8) return "s8";
if (ty == .s16) return "s16";
@@ -13829,12 +13784,11 @@ pub const Lowering = struct {
}
/// Get protocol info for a TypeId (if it's a protocol type).
/// Protocol lookup. Thin delegation to the canonical owner
/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9
/// callers (dispatch sites here + `calls.zig`) reach it.
pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo {
if (ty.isBuiltin()) return null;
const info = self.module.types.get(ty);
if (info != .@"struct") return null;
const name = self.module.types.getString(info.@"struct".name);
return self.program_index.protocol_decl_map.get(name);
return self.protocolResolver().getProtocolInfo(ty);
}
/// Get or create thunks for a (protocol, concrete_type) pair.
@@ -14257,6 +14211,10 @@ pub const Lowering = struct {
return .{ .l = self };
}
pub fn protocolResolver(self: *Lowering) ProtocolResolver {
return .{ .l = self };
}
/// Lower the `xx` operator (type coercion).
/// Uses self.target_type for context when available. Handles:
/// - Any → concrete type: unbox_any

107
src/ir/protocols.test.zig Normal file
View File

@@ -0,0 +1,107 @@
// Tests for protocols.zig — the protocol/impl LOOKUP owner (`ProtocolResolver`).
// Reached via `ir.ProtocolResolver{ .l = &lowering }`, mirroring calls.test.zig /
// generics.test.zig. Covers the pure conformance queries moved out of `Lowering`
// in A4.2 sub-step 2 (lookup increment); registration + emission stay in
// `Lowering`, so their plan tests land with later increments.
const std = @import("std");
const ast = @import("../ast.zig");
const Node = ast.Node;
const ir_mod = @import("ir.zig");
const TypeId = ir_mod.TypeId;
const FuncId = ir_mod.FuncId;
const Lowering = ir_mod.Lowering;
const ProtocolResolver = ir_mod.ProtocolResolver;
fn protoMethodReq(name: []const u8) ast.ProtocolMethodDecl {
// A required (no default body) method, no params, void return.
return .{ .name = name, .params = &.{}, .param_names = &.{}, .return_type = null, .default_body = null };
}
test "protocols: getProtocolInfo resolves registered protocol structs only" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const pr = ProtocolResolver{ .l = &l };
const methods = [_]ast.ProtocolMethodDecl{protoMethodReq("draw")};
const pd = ast.ProtocolDecl{ .name = "Drawable", .methods = &methods };
l.registerProtocolDecl(&pd);
// The registered protocol struct resolves to its decl info.
const drawable_ty = module.types.findByName(module.types.internString("Drawable")).?;
const info = pr.getProtocolInfo(drawable_ty).?;
try std.testing.expectEqualStrings("Drawable", info.name);
try std.testing.expectEqual(@as(usize, 1), info.methods.len);
// A builtin and an unrelated plain struct are not protocols.
try std.testing.expect(pr.getProtocolInfo(.s32) == null);
const plain = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Point"), .fields = &.{} } });
try std.testing.expect(pr.getProtocolInfo(plain) == null);
// The Lowering wrapper delegates to the same result.
try std.testing.expect(l.getProtocolInfo(drawable_ty) != null);
}
test "protocols: hasImplPlain reflects materialized thunks for a (protocol, type) pair" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const pr = ProtocolResolver{ .l = &l };
const circle = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Circle"), .fields = &.{} } });
// No thunks yet → not materialized.
try std.testing.expect(!pr.hasImplPlain("Drawable", circle));
// Materialize the (Drawable, Circle) thunk slot the way `getOrCreateThunks`
// does — key "Proto\x00<formatTypeName>". hasImplPlain must then see it.
const key = std.fmt.allocPrint(alloc, "Drawable\x00{s}", .{l.formatTypeName(circle)}) catch unreachable;
l.protocol_thunk_map.put(key, &[_]FuncId{}) catch unreachable;
try std.testing.expect(pr.hasImplPlain("Drawable", circle));
// A different protocol over the same type is still unmaterialized.
try std.testing.expect(!pr.hasImplPlain("Hash", circle));
}
test "protocols: packArgConformsTo at the impl-declaration level (non-parameterised)" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var l = Lowering.init(&module);
const pr = ProtocolResolver{ .l = &l };
// Shape :: protocol { draw :: (); } (non-parameterised, one required method)
const methods = [_]ast.ProtocolMethodDecl{protoMethodReq("draw")};
const pd = ast.ProtocolDecl{ .name = "Shape", .methods = &methods };
l.registerProtocolDecl(&pd);
const circle = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Circle"), .fields = &.{} } });
// No `Circle.draw` registered → does NOT conform.
try std.testing.expect(!pr.packArgConformsTo("Shape", circle));
// Register the impl method `Circle.draw` (how registerImplBlock records a
// non-parameterised impl) → now conforms.
const body = alloc.create(Node) catch unreachable;
body.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .block = .{ .stmts = &.{} } } };
const draw_fd = ast.FnDecl{ .name = "Circle.draw", .params = &.{}, .return_type = null, .body = body };
l.program_index.fn_ast_map.put("Circle.draw", &draw_fd) catch unreachable;
try std.testing.expect(pr.packArgConformsTo("Shape", circle));
// An arg already erased to the protocol struct itself trivially conforms.
const shape_ty = module.types.findByName(module.types.internString("Shape")).?;
try std.testing.expect(pr.packArgConformsTo("Shape", shape_ty));
// An unregistered protocol name conforms to nothing.
try std.testing.expect(!pr.packArgConformsTo("Nope", circle));
}

85
src/ir/protocols.zig Normal file
View File

@@ -0,0 +1,85 @@
const std = @import("std");
const types = @import("types.zig");
const lower = @import("lower.zig");
const program_index_mod = @import("program_index.zig");
const TypeId = types.TypeId;
const Lowering = lower.Lowering;
const ProtocolDeclInfo = program_index_mod.ProtocolDeclInfo;
/// Protocol / impl LOOKUP (architecture phase A4.2, first increment), extracted
/// from `Lowering`. Owns the read-only conformance queries:
/// - `getProtocolInfo` — is a type a registered protocol, and its method table,
/// - `hasImplPlain` — has a (protocol, type) pair had its thunks materialized,
/// - `packArgConformsTo` — does a type conform to a protocol at the
/// impl-declaration level (for protocol-pack `..xs: P` elements).
///
/// A `*Lowering` facade (Principle 5, like `GenericResolver` / `CallResolver`):
/// these read the protocol/impl registries (`protocol_decl_map` /
/// `protocol_ast_map` in `ProgramIndex`; `protocol_thunk_map` / `param_impl_map`
/// on `Lowering`) plus the type table, so it borrows `*Lowering` rather than
/// re-threading every map. Registration (`register*`) and IR emission
/// (`createProtocolThunk` / `buildProtocolValue` / `tryUserConversion`) stay in
/// `Lowering` for the later A4.2 increments — this step moves only pure lookup.
pub const ProtocolResolver = struct {
l: *Lowering,
/// If `ty` is a registered protocol struct, return its decl info (method
/// table); else null.
pub fn getProtocolInfo(self: ProtocolResolver, ty: TypeId) ?ProtocolDeclInfo {
if (ty.isBuiltin()) return null;
const info = self.l.module.types.get(ty);
if (info != .@"struct") return null;
const name = self.l.module.types.getString(info.@"struct".name);
return self.l.program_index.protocol_decl_map.get(name);
}
/// Have the thunks for (protocol `p_name`, concrete `ty`) been materialized?
/// `protocol_thunk_map` is populated lazily when a protocol VALUE is created
/// with `xx`, so this answers "has erasure already happened for this pair".
pub fn hasImplPlain(self: ProtocolResolver, p_name: []const u8, ty: TypeId) bool {
const ty_name = self.l.formatTypeName(ty);
const thunk_key = std.fmt.allocPrint(self.l.alloc, "{s}\x00{s}", .{ p_name, ty_name }) catch return false;
return self.l.protocol_thunk_map.contains(thunk_key);
}
/// Does `ty` conform to protocol `p_name` (under SOME type-args for a
/// parameterised protocol)? Used to check protocol-pack elements
/// (`..xs: P`), where each element's protocol type-args are inferred from
/// its impl rather than written out.
///
/// Conformance is queried at the IMPL-DECLARATION level (not via
/// `protocol_thunk_map`, which is only populated lazily when a protocol
/// VALUE is created with `xx`):
/// - Parameterised `P`: any `param_impl_map` key `P\x00<args>\x00<mangle(ty)>`.
/// - Non-parameterised `P`: every required (non-default) method `m` is
/// registered as `<ty>.<m>` in `fn_ast_map` (how `registerImplBlock`
/// records a non-parameterised impl).
/// An arg already of the protocol's own (erased) type trivially conforms.
pub fn packArgConformsTo(self: ProtocolResolver, p_name: []const u8, ty: TypeId) bool {
// Arg already erased to the protocol struct itself (e.g. `xx a`).
if (!ty.isBuiltin()) {
const info = self.l.module.types.get(ty);
if (info == .@"struct" and info.@"struct".is_protocol and
std.mem.eql(u8, self.l.module.types.getString(info.@"struct".name), p_name)) return true;
}
const pd = self.l.program_index.protocol_ast_map.get(p_name) orelse return false;
if (pd.type_params.len > 0) {
const prefix = std.fmt.allocPrint(self.l.alloc, "{s}\x00", .{p_name}) catch return false;
const suffix = std.fmt.allocPrint(self.l.alloc, "\x00{s}", .{self.l.mangleTypeName(ty)}) catch return false;
var it = self.l.param_impl_map.keyIterator();
while (it.next()) |k| {
if (std.mem.startsWith(u8, k.*, prefix) and std.mem.endsWith(u8, k.*, suffix)) return true;
}
return false;
}
// Non-parameterised: require each non-default method as `<ty>.<m>`.
const ty_name = self.l.formatTypeName(ty);
for (pd.methods) |m| {
if (m.default_body != null) continue;
const q = std.fmt.allocPrint(self.l.alloc, "{s}.{s}", .{ ty_name, m.name }) catch return false;
if (!self.l.program_index.fn_ast_map.contains(q)) return false;
}
return true;
}
};