refactor(ir): add ResolveEnv + TypeResolver shell; own primitives + compounds (A2.1)
Architecture phase A2.1 -- behavior-preserving. Introduce src/ir/type_resolver.zig as the canonical AST-type-node -> TypeId resolver (Principle 1), starting with: - ResolveEnv: the explicit resolution-context shape (Principle 2) -- type/pack/ comptime bindings + target_type. Defined now; consumed as A2.2/A2.3 move the cases that need it. - TypeResolver.resolvePrimitive(name): the builtin keyword table, MOVED here from type_bridge.resolveTypePrimitive (now a re-export -> single source; its 7 callers are unaffected; no import cycle). - TypeResolver.resolveCompound(node, inner): the structural compound types *T / [*]T / []T / ?T / [N]T. Element types recurse via inner.resolveInner (an anytype callback) so generic structs / bindings in element position keep their full stateful resolution. Lowering.resolveTypeWithBindings duplicated the 5 simple compounds across its bindings and no-bindings blocks (10 arms). Both are replaced with a single self.typeResolver().resolveCompound(node, self) delegation; adds Lowering.resolveInner (recursion hook) + typeResolver() (by-value view). Deliberately deferred: tuples, closures, and function types stay on the existing pack-aware helpers (resolveClosure/Tuple/FunctionTypeWithBindings); A2.3 owns their pack-projection logic. Tests: src/ir/type_resolver.test.zig (resolvePrimitive keyword/null cases; resolveCompound for all 5 + null for non-compound; ResolveEnv defaults), wired into the ir.zig barrel. No new fallback path; no duplicate truth. Gate green: zig build, zig build test, bash tests/run_examples.sh (350 passed, 0 failed). lower.zig 19393 -> 19372.
This commit is contained in:
@@ -5,6 +5,7 @@ pub const print = @import("print.zig");
|
||||
pub const interp = @import("interp.zig");
|
||||
pub const lower = @import("lower.zig");
|
||||
pub const program_index = @import("program_index.zig");
|
||||
pub const type_resolver = @import("type_resolver.zig");
|
||||
|
||||
pub const TypeId = types.TypeId;
|
||||
pub const TypeInfo = types.TypeInfo;
|
||||
@@ -32,6 +33,8 @@ pub const Interpreter = interp.Interpreter;
|
||||
pub const Value = interp.Value;
|
||||
pub const Lowering = lower.Lowering;
|
||||
pub const ProgramIndex = program_index.ProgramIndex;
|
||||
pub const TypeResolver = type_resolver.TypeResolver;
|
||||
pub const ResolveEnv = type_resolver.ResolveEnv;
|
||||
|
||||
pub const compiler_hooks = @import("compiler_hooks.zig");
|
||||
pub const emit_llvm = @import("emit_llvm.zig");
|
||||
@@ -51,6 +54,7 @@ pub const print_tests = @import("print.test.zig");
|
||||
pub const interp_tests = @import("interp.test.zig");
|
||||
pub const lower_tests = @import("lower.test.zig");
|
||||
pub const program_index_tests = @import("program_index.test.zig");
|
||||
pub const type_resolver_tests = @import("type_resolver.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");
|
||||
|
||||
@@ -19,6 +19,7 @@ const TemplateParam = program_index_mod.TemplateParam;
|
||||
const ProtocolDeclInfo = program_index_mod.ProtocolDeclInfo;
|
||||
const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo;
|
||||
const ModuleConstInfo = program_index_mod.ModuleConstInfo;
|
||||
const TypeResolver = @import("type_resolver.zig").TypeResolver;
|
||||
|
||||
const TypeId = types.TypeId;
|
||||
const StringId = types.StringId;
|
||||
@@ -12730,6 +12731,24 @@ pub const Lowering = struct {
|
||||
return self.resolveTypeWithBindings(type_ann);
|
||||
}
|
||||
|
||||
/// Construct a `TypeResolver` view over the current lowering state (borrows
|
||||
/// only; cheap by-value, reflects current `diagnostics` / `program_index`).
|
||||
fn typeResolver(self: *Lowering) TypeResolver {
|
||||
return .{
|
||||
.alloc = self.alloc,
|
||||
.types = &self.module.types,
|
||||
.diagnostics = self.diagnostics,
|
||||
.index = &self.program_index,
|
||||
};
|
||||
}
|
||||
|
||||
/// Inner-type recursion hook for `TypeResolver.resolveCompound`: resolves a
|
||||
/// child type node through the full stateful resolver, so generic structs /
|
||||
/// bindings / aliases in element position keep their resolution.
|
||||
pub fn resolveInner(self: *Lowering, node: *const Node) TypeId {
|
||||
return self.resolveTypeWithBindings(node);
|
||||
}
|
||||
|
||||
/// Resolve a type node, checking type_bindings first for generic type params.
|
||||
fn resolveTypeWithBindings(self: *Lowering, node: *const Node) TypeId {
|
||||
// Pack-index in a type position: `$<pack>[<lit>]` resolves to the
|
||||
@@ -12774,6 +12793,11 @@ pub const Lowering = struct {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Structural compound types (`*T`, `[*]T`, `[]T`, `?T`, `[N]T`) are
|
||||
// owned by TypeResolver (A2.1). Element types recurse through the full
|
||||
// stateful resolver (`resolveInner` → here) so generic structs /
|
||||
// bindings in element position keep their resolution.
|
||||
if (self.typeResolver().resolveCompound(node, self)) |t| return t;
|
||||
if (self.type_bindings) |tb| {
|
||||
switch (node.data) {
|
||||
.type_expr => |te| {
|
||||
@@ -12786,31 +12810,6 @@ pub const Lowering = struct {
|
||||
.identifier => |id| {
|
||||
if (tb.get(id.name)) |ty| return ty;
|
||||
},
|
||||
// Compound types: resolve inner types with bindings
|
||||
.slice_type_expr => |st| {
|
||||
const elem = self.resolveTypeWithBindings(st.element_type);
|
||||
return self.module.types.sliceOf(elem);
|
||||
},
|
||||
.pointer_type_expr => |pt| {
|
||||
const pointee = self.resolveTypeWithBindings(pt.pointee_type);
|
||||
return self.module.types.ptrTo(pointee);
|
||||
},
|
||||
.many_pointer_type_expr => |mp| {
|
||||
const elem = self.resolveTypeWithBindings(mp.element_type);
|
||||
return self.module.types.manyPtrTo(elem);
|
||||
},
|
||||
.optional_type_expr => |ot| {
|
||||
const child = self.resolveTypeWithBindings(ot.inner_type);
|
||||
return self.module.types.optionalOf(child);
|
||||
},
|
||||
.array_type_expr => |at| {
|
||||
const elem = self.resolveTypeWithBindings(at.element_type);
|
||||
const len: u32 = blk: {
|
||||
if (at.length.data == .int_literal) break :blk @intCast(at.length.data.int_literal.value);
|
||||
break :blk 0;
|
||||
};
|
||||
return self.module.types.arrayOf(elem, len);
|
||||
},
|
||||
.parameterized_type_expr => |pt| {
|
||||
return self.resolveParameterizedWithBindings(&pt);
|
||||
},
|
||||
@@ -12834,30 +12833,10 @@ pub const Lowering = struct {
|
||||
if (node.data == .call) {
|
||||
return self.resolveTypeCallWithBindings(&node.data.call);
|
||||
}
|
||||
// Handle compound types that may contain generic structs (e.g., *List(ViewChild))
|
||||
// These need the lowerer's resolveType to properly instantiate generics.
|
||||
// Pointers / slices / many-pointers / optionals / arrays are owned by
|
||||
// TypeResolver (handled above). The pack-aware tuple / closure /
|
||||
// function shapes resolve here — A2.3 owns their pack projection logic.
|
||||
switch (node.data) {
|
||||
.pointer_type_expr => |pt| {
|
||||
const pointee = self.resolveTypeWithBindings(pt.pointee_type);
|
||||
return self.module.types.ptrTo(pointee);
|
||||
},
|
||||
.slice_type_expr => |st| {
|
||||
const elem = self.resolveTypeWithBindings(st.element_type);
|
||||
return self.module.types.sliceOf(elem);
|
||||
},
|
||||
.many_pointer_type_expr => |mp| {
|
||||
const elem = self.resolveTypeWithBindings(mp.element_type);
|
||||
return self.module.types.manyPtrTo(elem);
|
||||
},
|
||||
.optional_type_expr => |ot| {
|
||||
const child = self.resolveTypeWithBindings(ot.inner_type);
|
||||
return self.module.types.optionalOf(child);
|
||||
},
|
||||
.array_type_expr => |at| {
|
||||
const elem = self.resolveTypeWithBindings(at.element_type);
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ const TypeId = ir_types.TypeId;
|
||||
const TypeInfo = ir_types.TypeInfo;
|
||||
const TypeTable = ir_types.TypeTable;
|
||||
const StringId = ir_types.StringId;
|
||||
const type_resolver = @import("type_resolver.zig");
|
||||
|
||||
// ── AST Node → TypeId ───────────────────────────────────────────────────
|
||||
// Resolve an AST type node into an IR TypeId. Used during lowering when
|
||||
@@ -251,37 +252,11 @@ fn resolveTypeName(name: []const u8, table: *TypeTable) TypeId {
|
||||
return table.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } });
|
||||
}
|
||||
|
||||
pub fn resolveTypePrimitive(name: []const u8) ?TypeId {
|
||||
if (name.len == 0) return null;
|
||||
// Fast path for common types
|
||||
if (std.mem.eql(u8, name, "s64")) return .s64;
|
||||
if (std.mem.eql(u8, name, "s32")) return .s32;
|
||||
if (std.mem.eql(u8, name, "s16")) return .s16;
|
||||
if (std.mem.eql(u8, name, "s8")) return .s8;
|
||||
if (std.mem.eql(u8, name, "u64")) return .u64;
|
||||
if (std.mem.eql(u8, name, "u32")) return .u32;
|
||||
if (std.mem.eql(u8, name, "u16")) return .u16;
|
||||
if (std.mem.eql(u8, name, "u8")) return .u8;
|
||||
if (std.mem.eql(u8, name, "f32")) return .f32;
|
||||
if (std.mem.eql(u8, name, "f64")) return .f64;
|
||||
if (std.mem.eql(u8, name, "bool")) return .bool;
|
||||
if (std.mem.eql(u8, name, "string")) return .string;
|
||||
if (std.mem.eql(u8, name, "void")) return .void;
|
||||
if (std.mem.eql(u8, name, "Any")) return .any;
|
||||
// Type values are runtime-representable as Any-shaped pairs:
|
||||
// `{ tag = .any.index() (the meta-marker), value = TypeId.index() }`.
|
||||
// Lets `t : Type = f64; t == s64; print(t)` all route through the
|
||||
// existing Any infrastructure — boxing/unboxing, `case type:`
|
||||
// dispatch, runtime `type_name(t)` via the type-name lookup
|
||||
// table. Comparison decomposes via the eq fold path
|
||||
// (`isStaticTypeRef`) for static literals; runtime-var vs
|
||||
// literal compares decompose at `lowerBinaryOp`.
|
||||
if (std.mem.eql(u8, name, "Type")) return .any;
|
||||
if (std.mem.eql(u8, name, "noreturn")) return .noreturn;
|
||||
if (std.mem.eql(u8, name, "usize")) return .usize;
|
||||
if (std.mem.eql(u8, name, "isize")) return .isize;
|
||||
return null;
|
||||
}
|
||||
/// Builtin primitive keyword → TypeId. The keyword table now lives in
|
||||
/// `type_resolver.zig` (architecture phase A2.1, `TypeResolver.resolvePrimitive`);
|
||||
/// re-exported here so existing callers are unaffected while `type_bridge` is
|
||||
/// retired (A2.2). Single source of truth: the table is defined once, there.
|
||||
pub const resolveTypePrimitive = type_resolver.TypeResolver.resolvePrimitive;
|
||||
|
||||
fn resolveArrayType(at: *const ast.ArrayTypeExpr, table: *TypeTable) TypeId {
|
||||
const elem = resolveAstType(at.element_type, table);
|
||||
|
||||
86
src/ir/type_resolver.test.zig
Normal file
86
src/ir/type_resolver.test.zig
Normal file
@@ -0,0 +1,86 @@
|
||||
const std = @import("std");
|
||||
const ast = @import("../ast.zig");
|
||||
const Node = ast.Node;
|
||||
const types = @import("types.zig");
|
||||
const TypeTable = types.TypeTable;
|
||||
const TypeId = types.TypeId;
|
||||
const tr_mod = @import("type_resolver.zig");
|
||||
const TypeResolver = tr_mod.TypeResolver;
|
||||
const ResolveEnv = tr_mod.ResolveEnv;
|
||||
const ProgramIndex = @import("program_index.zig").ProgramIndex;
|
||||
|
||||
fn typeExpr(name: []const u8) Node {
|
||||
return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = name, .is_generic = false } } };
|
||||
}
|
||||
|
||||
/// Stand-in for `Lowering.resolveInner`: the real hook recurses the full
|
||||
/// stateful resolver; here element types are always primitives, resolved via
|
||||
/// the keyword table.
|
||||
const PrimInner = struct {
|
||||
pub fn resolveInner(_: PrimInner, node: *const Node) TypeId {
|
||||
return switch (node.data) {
|
||||
.type_expr => |te| TypeResolver.resolvePrimitive(te.name) orelse .unresolved,
|
||||
else => .unresolved,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
test "TypeResolver.resolvePrimitive maps builtin keywords, null otherwise" {
|
||||
try std.testing.expectEqual(@as(?TypeId, .s64), TypeResolver.resolvePrimitive("s64"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .bool), TypeResolver.resolvePrimitive("bool"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .f64), TypeResolver.resolvePrimitive("f64"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .void), TypeResolver.resolvePrimitive("void"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .any), TypeResolver.resolvePrimitive("Any"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .any), TypeResolver.resolvePrimitive("Type"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .usize), TypeResolver.resolvePrimitive("usize"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .isize), TypeResolver.resolvePrimitive("isize"));
|
||||
try std.testing.expectEqual(@as(?TypeId, .noreturn), TypeResolver.resolvePrimitive("noreturn"));
|
||||
// Non-primitives (aliases / generics / named structs) defer to the caller.
|
||||
try std.testing.expect(TypeResolver.resolvePrimitive("List") == null);
|
||||
try std.testing.expect(TypeResolver.resolvePrimitive("ShaderHandle") == null);
|
||||
try std.testing.expect(TypeResolver.resolvePrimitive("") == null);
|
||||
}
|
||||
|
||||
test "TypeResolver.resolveCompound builds structural compound types" {
|
||||
const alloc = std.testing.allocator;
|
||||
var table = TypeTable.init(alloc);
|
||||
defer table.deinit();
|
||||
var index = ProgramIndex.init(alloc);
|
||||
defer index.deinit();
|
||||
const tr = TypeResolver{ .alloc = alloc, .types = &table, .diagnostics = null, .index = &index };
|
||||
const inner = PrimInner{};
|
||||
|
||||
var s64n = typeExpr("s64");
|
||||
var u8n = typeExpr("u8");
|
||||
var f32n = typeExpr("f32");
|
||||
var booln = typeExpr("bool");
|
||||
var s32n = typeExpr("s32");
|
||||
|
||||
var ptr = Node{ .span = .{ .start = 0, .end = 0 }, .data = .{ .pointer_type_expr = .{ .pointee_type = &s64n } } };
|
||||
try std.testing.expectEqual(@as(?TypeId, table.ptrTo(.s64)), tr.resolveCompound(&ptr, inner));
|
||||
|
||||
var mptr = Node{ .span = .{ .start = 0, .end = 0 }, .data = .{ .many_pointer_type_expr = .{ .element_type = &u8n } } };
|
||||
try std.testing.expectEqual(@as(?TypeId, table.manyPtrTo(.u8)), tr.resolveCompound(&mptr, inner));
|
||||
|
||||
var slice = Node{ .span = .{ .start = 0, .end = 0 }, .data = .{ .slice_type_expr = .{ .element_type = &f32n } } };
|
||||
try std.testing.expectEqual(@as(?TypeId, table.sliceOf(.f32)), tr.resolveCompound(&slice, inner));
|
||||
|
||||
var opt = Node{ .span = .{ .start = 0, .end = 0 }, .data = .{ .optional_type_expr = .{ .inner_type = &booln } } };
|
||||
try std.testing.expectEqual(@as(?TypeId, table.optionalOf(.bool)), tr.resolveCompound(&opt, inner));
|
||||
|
||||
var len = Node{ .span = .{ .start = 0, .end = 0 }, .data = .{ .int_literal = .{ .value = 3 } } };
|
||||
var arr = Node{ .span = .{ .start = 0, .end = 0 }, .data = .{ .array_type_expr = .{ .length = &len, .element_type = &s32n } } };
|
||||
try std.testing.expectEqual(@as(?TypeId, table.arrayOf(.s32, 3)), tr.resolveCompound(&arr, inner));
|
||||
|
||||
// Non-compound nodes are not this resolver's responsibility → null, so the
|
||||
// caller continues with name / tuple / closure / generic resolution.
|
||||
var name = typeExpr("List");
|
||||
try std.testing.expect(tr.resolveCompound(&name, inner) == null);
|
||||
}
|
||||
|
||||
test "ResolveEnv default-constructs with all-null context" {
|
||||
const env = ResolveEnv{};
|
||||
try std.testing.expect(env.type_bindings == null);
|
||||
try std.testing.expect(env.pack_bindings == null);
|
||||
try std.testing.expect(env.target_type == null);
|
||||
}
|
||||
90
src/ir/type_resolver.zig
Normal file
90
src/ir/type_resolver.zig
Normal file
@@ -0,0 +1,90 @@
|
||||
const std = @import("std");
|
||||
const ast = @import("../ast.zig");
|
||||
const types = @import("types.zig");
|
||||
const errors = @import("../errors.zig");
|
||||
const program_index_mod = @import("program_index.zig");
|
||||
|
||||
const Node = ast.Node;
|
||||
const TypeId = types.TypeId;
|
||||
const TypeTable = types.TypeTable;
|
||||
const ProgramIndex = program_index_mod.ProgramIndex;
|
||||
|
||||
/// Explicit, caller-supplied resolution context (architecture Principle 2):
|
||||
/// the inputs that steer AST type-node resolution, replacing ad-hoc mutable
|
||||
/// `Lowering` fields (`type_bindings`, `pack_*`, `comptime_value_bindings`,
|
||||
/// `target_type`, …). A2.1 defines the shape; fields are consumed as later
|
||||
/// phases move the cases that need them (generics/aliases A2.2, packs A2.3).
|
||||
pub const ResolveEnv = struct {
|
||||
type_bindings: ?*const std.StringHashMap(TypeId) = null,
|
||||
pack_bindings: ?*const std.StringHashMap([]const TypeId) = null,
|
||||
pack_arg_types: ?*const std.StringHashMap([]const TypeId) = null,
|
||||
pack_constraints: ?*const std.StringHashMap([]const u8) = null,
|
||||
comptime_values: ?*const std.StringHashMap(i64) = null,
|
||||
target_type: ?TypeId = null,
|
||||
};
|
||||
|
||||
/// Canonical AST-type-node → `TypeId` resolver (architecture phase A2). As of
|
||||
/// A2.1 it owns the primitive-keyword table and the structural compound type
|
||||
/// constructors. Later phases fold in generics/aliases (A2.2) and pack
|
||||
/// projections (A2.3) and retire `src/ir/type_bridge.zig` (Principle 1).
|
||||
///
|
||||
/// Holds borrowed references only — constructed cheaply by value at each call
|
||||
/// site (`Lowering.typeResolver()`), so it always reflects current state.
|
||||
pub const TypeResolver = struct {
|
||||
alloc: std.mem.Allocator,
|
||||
types: *TypeTable,
|
||||
diagnostics: ?*errors.DiagnosticList,
|
||||
index: *ProgramIndex,
|
||||
|
||||
/// Builtin primitive keyword → `TypeId`; `null` for any non-primitive name
|
||||
/// (the caller then continues with generic / alias / named-struct
|
||||
/// resolution). Single source of truth for the builtin keyword set.
|
||||
/// Namespaced (no `self`) — primitive resolution is stateless.
|
||||
pub fn resolvePrimitive(name: []const u8) ?TypeId {
|
||||
if (name.len == 0) return null;
|
||||
if (std.mem.eql(u8, name, "s64")) return .s64;
|
||||
if (std.mem.eql(u8, name, "s32")) return .s32;
|
||||
if (std.mem.eql(u8, name, "s16")) return .s16;
|
||||
if (std.mem.eql(u8, name, "s8")) return .s8;
|
||||
if (std.mem.eql(u8, name, "u64")) return .u64;
|
||||
if (std.mem.eql(u8, name, "u32")) return .u32;
|
||||
if (std.mem.eql(u8, name, "u16")) return .u16;
|
||||
if (std.mem.eql(u8, name, "u8")) return .u8;
|
||||
if (std.mem.eql(u8, name, "f32")) return .f32;
|
||||
if (std.mem.eql(u8, name, "f64")) return .f64;
|
||||
if (std.mem.eql(u8, name, "bool")) return .bool;
|
||||
if (std.mem.eql(u8, name, "string")) return .string;
|
||||
if (std.mem.eql(u8, name, "void")) return .void;
|
||||
if (std.mem.eql(u8, name, "Any")) return .any;
|
||||
// `Type` values are runtime-representable as Any-shaped pairs
|
||||
// `{ tag = .any.index(), value = TypeId.index() }`, so `Type` maps to
|
||||
// `.any` and routes through the existing Any infrastructure.
|
||||
if (std.mem.eql(u8, name, "Type")) return .any;
|
||||
if (std.mem.eql(u8, name, "noreturn")) return .noreturn;
|
||||
if (std.mem.eql(u8, name, "usize")) return .usize;
|
||||
if (std.mem.eql(u8, name, "isize")) return .isize;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Structural compound types whose meaning is fully determined by their
|
||||
/// node kind and element type(s): `*T`, `[*]T`, `[]T`, `?T`, `[N]T`.
|
||||
/// Element types are resolved via `inner.resolveInner(node)` so generic
|
||||
/// structs / bindings / aliases in element position keep their full
|
||||
/// (caller-side, stateful) resolution. Returns `null` for any other node
|
||||
/// kind — names, tuples, closures/functions (pack-aware, A2.3),
|
||||
/// parameterized types, pack-index, `Self` — which the caller handles.
|
||||
pub fn resolveCompound(self: TypeResolver, node: *const Node, inner: anytype) ?TypeId {
|
||||
return switch (node.data) {
|
||||
.pointer_type_expr => |pt| self.types.ptrTo(inner.resolveInner(pt.pointee_type)),
|
||||
.many_pointer_type_expr => |mp| self.types.manyPtrTo(inner.resolveInner(mp.element_type)),
|
||||
.slice_type_expr => |st| self.types.sliceOf(inner.resolveInner(st.element_type)),
|
||||
.optional_type_expr => |ot| self.types.optionalOf(inner.resolveInner(ot.inner_type)),
|
||||
.array_type_expr => |at| blk: {
|
||||
const elem = inner.resolveInner(at.element_type);
|
||||
const len: u32 = if (at.length.data == .int_literal) @intCast(at.length.data.int_literal.value) else 0;
|
||||
break :blk self.types.arrayOf(elem, len);
|
||||
},
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user