lang 2.2: protocol-arg lookup + position-driven pack projection

Add the name-resolution primitives a `..pack.<name>` projection needs
(Decision 4). A protocol exposes two namespaces: type-args (the
`protocol($T, ...)` params) and runtime accessors (its methods — protocols
have no fields). Resolution is position-driven with no cross-namespace
fallback:

- lookupProtocolArg(protocol, name) -> ?u32   (type_params index)
- lookupProtocolField(protocol, name) -> ?u32 (methods index)
- resolvePackProjection(protocol, name, pos)  (.type_arg | .method | .not_found)

registerProtocolDecl now warns when a type-arg and a method share a name
(allowed, but `..pack.<name>` then resolves by position, which surprises
readers). 3 unit tests cover both namespaces, the position rule, and the
shadowing warning + deterministic resolution despite a shadow.

Projecting a *bound* pack (producing a new Pack of per-element results) waits
for call-site binding in Step 2.4; these primitives are what it will call
per element.
This commit is contained in:
agra
2026-05-29 16:00:03 +03:00
parent 4defadf513
commit fac235950d
2 changed files with 170 additions and 1 deletions

View File

@@ -555,3 +555,98 @@ test "lower: objcTypeEncodingFromSignature emits nested structs (CGRect)" {
defer alloc.free(e2);
try std.testing.expectEqualStrings("v@:{CGRect={CGPoint=dd}{CGSize=dd}}", e2);
}
// ── Pack projection name resolution (Feature 1, Step 2.2) ────────────
const errors = @import("../errors.zig");
fn typeKeyword(alloc: std.mem.Allocator, name: []const u8) *Node {
const n = alloc.create(Node) catch unreachable;
n.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = name, .is_generic = false } } };
return n;
}
fn protoMethod(name: []const u8) ast.ProtocolMethodDecl {
return .{ .name = name, .params = &.{}, .param_names = &.{}, .return_type = null, .default_body = null };
}
test "pack projection: type-arg vs method namespace lookups" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
// Wrap :: protocol(Target: Type) { wrap :: () -> Target; value :: () -> Target; }
const type_kw = typeKeyword(alloc, "Type");
defer alloc.destroy(type_kw);
const type_params = [_]ast.StructTypeParam{.{ .name = "Target", .constraint = type_kw }};
const methods = [_]ast.ProtocolMethodDecl{ protoMethod("wrap"), protoMethod("value") };
const pd = ast.ProtocolDecl{ .name = "Wrap", .methods = &methods, .type_params = &type_params };
lowering.registerProtocolDecl(&pd);
// type-arg namespace
try std.testing.expectEqual(@as(?u32, 0), lowering.lookupProtocolArg("Wrap", "Target"));
try std.testing.expectEqual(@as(?u32, null), lowering.lookupProtocolArg("Wrap", "wrap"));
try std.testing.expectEqual(@as(?u32, null), lowering.lookupProtocolArg("Nope", "Target"));
// method (runtime-accessor) namespace
try std.testing.expectEqual(@as(?u32, 0), lowering.lookupProtocolField("Wrap", "wrap"));
try std.testing.expectEqual(@as(?u32, 1), lowering.lookupProtocolField("Wrap", "value"));
try std.testing.expectEqual(@as(?u32, null), lowering.lookupProtocolField("Wrap", "Target"));
}
test "pack projection: position-driven resolution (Decision 4)" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
const type_kw = typeKeyword(alloc, "Type");
defer alloc.destroy(type_kw);
const type_params = [_]ast.StructTypeParam{.{ .name = "Target", .constraint = type_kw }};
const methods = [_]ast.ProtocolMethodDecl{protoMethod("wrap")};
const pd = ast.ProtocolDecl{ .name = "Wrap", .methods = &methods, .type_params = &type_params };
lowering.registerProtocolDecl(&pd);
// type position consults type-args only
try std.testing.expectEqual(Lowering.PackProjection{ .type_arg = 0 }, lowering.resolvePackProjection("Wrap", "Target", .type_position));
try std.testing.expectEqual(Lowering.PackProjection.not_found, lowering.resolvePackProjection("Wrap", "wrap", .type_position));
// value position consults methods only — no cross-namespace fallback
try std.testing.expectEqual(Lowering.PackProjection{ .method = 0 }, lowering.resolvePackProjection("Wrap", "wrap", .value_position));
try std.testing.expectEqual(Lowering.PackProjection.not_found, lowering.resolvePackProjection("Wrap", "Target", .value_position));
}
test "pack projection: same-name type-arg + method warns (Decision 4)" {
// Arena: DiagnosticList.addFmt allocates messages it never frees in deinit
// (mixed ownership with borrowed literals) — an arena keeps the leak
// checker clean without changing diagnostic semantics.
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 diags = errors.DiagnosticList.init(alloc, "", "test.sx");
defer diags.deinit();
var lowering = Lowering.init(&module);
lowering.diagnostics = &diags;
// A protocol whose type-arg and method share the name `value`.
const type_kw = typeKeyword(alloc, "Type");
defer alloc.destroy(type_kw);
const type_params = [_]ast.StructTypeParam{.{ .name = "value", .constraint = type_kw }};
const methods = [_]ast.ProtocolMethodDecl{protoMethod("value")};
const pd = ast.ProtocolDecl{ .name = "Shadowy", .methods = &methods, .type_params = &type_params };
lowering.registerProtocolDecl(&pd);
var warned = false;
for (diags.items.items) |d| {
if (d.level == .warn and std.mem.indexOf(u8, d.message, "type-arg and method both named 'value'") != null) warned = true;
}
try std.testing.expect(warned);
// Position still resolves deterministically despite the shadow.
try std.testing.expectEqual(Lowering.PackProjection{ .type_arg = 0 }, lowering.resolvePackProjection("Shadowy", "value", .type_position));
try std.testing.expectEqual(Lowering.PackProjection{ .method = 0 }, lowering.resolvePackProjection("Shadowy", "value", .value_position));
}

View File

@@ -11136,7 +11136,21 @@ pub const Lowering = struct {
/// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... }
/// Non-inline protocols: { ctx: *void, __vtable: *void }
/// Also stores protocol info for dispatch and vtable struct type for vtable protocols.
fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void {
pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void {
// Decision 4 soft-convention warning: a type-arg and a method (the
// "runtime accessor" namespace — protocols have no fields) sharing a
// name is allowed, but `..pack.<name>` then resolves by *position*
// rather than by precedence, which surprises readers. Alert at decl.
for (pd.type_params) |tp| {
for (pd.methods) |m| {
if (std.mem.eql(u8, tp.name, m.name)) {
if (self.diagnostics) |diags| {
diags.addFmt(.warn, null, "protocol '{s}' declares type-arg and method both named '{s}'; `..pack.{s}` resolves by position (type-arg in type position, method in value position)", .{ pd.name, tp.name, tp.name });
}
}
}
}
// Parameterised protocols are compile-time-only — no vtable, no boxed
// instance struct. Methods reference unbound type params (e.g.
// `convert :: () -> Target`) that only get a concrete TypeId per
@@ -11235,6 +11249,66 @@ pub const Lowering = struct {
}
}
// ── Pack projection name resolution (Feature 1, Decision 4) ──────────
//
// A `..pack.<name>` projection can target two protocol namespaces:
// - type-arg namespace: the `protocol($T, ...)` params.
// - runtime-accessor namespace: the protocol's methods (protocols have
// no fields; a zero-arg method like `value` is the accessor).
// Resolution is POSITION-driven, not precedence-driven: type position
// consults type-args, value position consults methods, with NO
// cross-namespace fallback.
pub const ProjectionPosition = enum { type_position, value_position };
pub const PackProjection = union(enum) {
type_arg: u32, // index into the protocol's `type_params`
method: u32, // index into the protocol's `methods`
not_found, // `name` absent from the position-selected namespace
};
/// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`).
/// Returns the `type_params` index, or null (also for unknown protocols).
pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
const pd = self.protocol_ast_map.get(protocol_name) orelse return null;
for (pd.type_params, 0..) |tp, i| {
if (std.mem.eql(u8, tp.name, name)) return @intCast(i);
}
return null;
}
/// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods
/// — protocols have no fields). Returns the `methods` index, or null.
pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 {
const pd = self.protocol_ast_map.get(protocol_name) orelse return null;
for (pd.methods, 0..) |m, i| {
if (std.mem.eql(u8, m.name, name)) return @intCast(i);
}
return null;
}
/// Resolve `..pack.<name>` against `protocol_name` by position (Decision 4).
/// No cross-namespace fallback: a value-position name that exists only as a
/// type-arg (or vice versa) is `.not_found`, letting the caller emit a
/// position-specific diagnostic (G3, Step 2.7).
pub fn resolvePackProjection(
self: *Lowering,
protocol_name: []const u8,
name: []const u8,
pos: ProjectionPosition,
) PackProjection {
return switch (pos) {
.type_position => if (self.lookupProtocolArg(protocol_name, name)) |i|
.{ .type_arg = i }
else
.not_found,
.value_position => if (self.lookupProtocolField(protocol_name, name)) |i|
.{ .method = i }
else
.not_found,
};
}
/// Register a foreign-class declaration. The alias goes into
/// `foreign_class_map` for method-dispatch lookup. The underlying
/// type (e.g. `*Activity`) is resolved via the existing struct