From 81d332dfb0fa99c0a212f433f8cbb7c0444a61f5 Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 2 Jun 2026 21:56:03 +0300 Subject: [PATCH] refactor(ir): extract protocol/impl lookup into protocols.zig (A4.2 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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. --- src/ir/ir.zig | 3 ++ src/ir/lower.zig | 68 +++++------------------- src/ir/protocols.test.zig | 107 ++++++++++++++++++++++++++++++++++++++ src/ir/protocols.zig | 85 ++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 55 deletions(-) create mode 100644 src/ir/protocols.test.zig create mode 100644 src/ir/protocols.zig diff --git a/src/ir/ir.zig b/src/ir/ir.zig index d906dbf..42b8922 100644 --- a/src/ir/ir.zig +++ b/src/ir/ir.zig @@ -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"); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9c419b3..f298ed1 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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\x00`. - /// - Non-parameterised `P`: every required (non-default) method `m` is - /// registered as `.` 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 `.`. - 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 diff --git a/src/ir/protocols.test.zig b/src/ir/protocols.test.zig new file mode 100644 index 0000000..abe4a59 --- /dev/null +++ b/src/ir/protocols.test.zig @@ -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". 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)); +} diff --git a/src/ir/protocols.zig b/src/ir/protocols.zig new file mode 100644 index 0000000..51ca63e --- /dev/null +++ b/src/ir/protocols.zig @@ -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\x00`. + /// - Non-parameterised `P`: every required (non-default) method `m` is + /// registered as `.` 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 `.`. + 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; + } +};