mem: implicit-context foundation + many compiler fixes

The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):

  Step 1 — `CAllocator :: struct {}` stateless allocator in
    library/modules/allocators.sx, delegating directly to
    libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
    `func_ref: FuncId` leaf so nested aggregates can carry function
    pointers (the inline Allocator value's fn-ptr fields). Switch
    sites updated in emit_llvm.zig, print.zig, interp.zig.

  Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
    a static `__sx_default_context` global with a nested-aggregate
    init_val pointing at the CAllocator → Allocator thunks. The
    second-pass `initVtableGlobals` in emit_llvm.zig is generalised
    to handle `.aggregate` init_vals (re-emits after func_map is
    populated so func_ref leaves resolve to real symbols).

Also folded in from earlier work this session:

  - Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
    through `context.allocator` via the new `allocViaContext` helper.
  - interp.zig: `marshalForeignArg` double-offset bug fixed —
    `heapSlice` already adds `hp.offset` to the slice ptr, so the
    extra `+ hp.offset` was scribbling memcpy/memset into adjacent
    heap state, corrupting `heap.items[0]`. Symptom: `build_format`
    at comptime produced zero bytes, all `print` calls failed.
  - Lazy lowering: `lazyLowerFunction` now declares foreign-body
    functions as extern stubs in the local (comptime) module so
    cross-module foreign calls resolve.
  - Allocator API: all stdlib allocators on one-line `init() -> *T`
    (CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
    backed; BufAlloc: embeds state at head of user buffer).
  - issues 0038 (transitive #import), 0039 (chess + stdlib migration
    fallout), 0040 (generic struct method dot-dispatch), 0041
    (pointer types as type-arg), 0042 (alias name resolution) — all
    fixed; regression tests in examples/.
  - Diagnostic: `emitError` now embeds the lowering's
    `current_source_file` and enclosing function in the literal
    message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
    emit site so misattributed spans can't hide where the failure
    is.
  - tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
    (interp/codegen parity tester) added.

Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.
This commit is contained in:
agra
2026-05-24 22:59:20 +03:00
parent 0ba41b2980
commit 29784c22a8
63 changed files with 3448 additions and 1207 deletions

View File

@@ -254,30 +254,98 @@ fn selfExePath(allocator: std.mem.Allocator) ![]const u8 {
/// A resolved module: the fully-resolved declarations of a single .sx file,
/// with its own scope tracking which names are defined.
///
/// Imports are non-transitive. `scope` is intentionally *narrow*: it
/// contains only the names of decls authored in THIS file (plus namespaced
/// import aliases the file introduces). Visibility for names from
/// flat-imported modules is computed at lookup time by joining the
/// importer's `scope` with each direct flat-import's `scope` via
/// `import_graph` — this lets cyclic imports (e.g. std.sx ↔ allocators.sx)
/// resolve correctly even though one side of the cycle is skipped during
/// `resolveImports` recursion.
///
/// `decls` remains the full transitive flat list so the global lowering
/// pass can resolve a body in B that calls into C even though A never
/// imported C directly.
pub const ResolvedModule = struct {
path: []const u8,
/// Full flat decl list: own decls + every transitively-imported module's
/// own decls (deduped by name). Walked by `lowerRoot`/`scanDecls` so
/// transitive callees stay resolvable when their callers are lowered.
decls: []const *Node,
/// Decls authored in this file. What flat importers of THIS module see
/// (their visibility BFS joins these names in via `import_graph`).
own_decls: []const *Node,
/// Names authored in this file (plus namespace aliases this file
/// introduces). Used as the per-file leaf in the visibility lookup;
/// importers do NOT splice this into their own scope — they walk the
/// import graph at query time instead.
scope: std.StringHashMap(void),
/// Try to add a declaration. Returns true if added, false if name already in scope.
pub fn addDecl(self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), decl: *Node) !bool {
/// Add a declaration authored in this file. Updates scope + own_decls +
/// the global flat decl list; dedups by name through `seen_list` (which
/// already holds names previously appended via `mergeFlat`, so an
/// authored decl that collides with a transitively-imported one stays
/// out of the global list while still entering `own_decls` for
/// importer-visibility purposes).
pub fn addOwnDecl(
self: *ResolvedModule,
allocator: std.mem.Allocator,
list: *std.ArrayList(*Node),
own_list: *std.ArrayList(*Node),
seen_list: *std.StringHashMap(void),
decl: *Node,
) !bool {
var append_to_global = true;
if (decl.data.declName()) |name| {
if (self.scope.contains(name)) return false;
try self.scope.put(name, {});
if (seen_list.contains(name)) {
append_to_global = false;
} else {
try seen_list.put(name, {});
}
}
try list.append(allocator, decl);
if (append_to_global) try list.append(allocator, decl);
try own_list.append(allocator, decl);
return true;
}
/// Merge another module's decls as flat imports (skipping duplicates).
pub fn mergeFlat(self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), other: ResolvedModule) !void {
/// Flat-import another module. The imported names are NOT added to
/// `self.scope` — visibility joins per-file scopes at lookup time via
/// `import_graph`. We only need to append `other.decls` (the full
/// transitive list) to the global `list` so the lowering pass can
/// still resolve transitively-imported callees. Deduped by name.
pub fn mergeFlat(
self: *ResolvedModule,
allocator: std.mem.Allocator,
list: *std.ArrayList(*Node),
seen_list: *std.StringHashMap(void),
other: ResolvedModule,
) !void {
_ = self;
for (other.decls) |decl| {
_ = try self.addDecl(allocator, list, decl);
if (decl.data.declName()) |name| {
if (seen_list.contains(name)) continue;
try seen_list.put(name, {});
}
try list.append(allocator, decl);
}
}
/// Add another module as a namespaced import.
pub fn addNamespace(self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), name: []const u8, other: ResolvedModule, span: ast.Span) !void {
/// Add another module as a namespaced import. The alias `name` becomes
/// part of this module's own decls (so a flat-importer of this module
/// sees the alias one hop out — matching authored names).
pub fn addNamespace(
self: *ResolvedModule,
allocator: std.mem.Allocator,
list: *std.ArrayList(*Node),
own_list: *std.ArrayList(*Node),
seen_list: *std.StringHashMap(void),
name: []const u8,
other: ResolvedModule,
span: ast.Span,
) !void {
const ns_node = try allocator.create(Node);
ns_node.* = .{
.span = span,
@@ -287,11 +355,19 @@ pub const ResolvedModule = struct {
} },
};
try self.scope.put(name, {});
try seen_list.put(name, {});
try list.append(allocator, ns_node);
try own_list.append(allocator, ns_node);
}
pub fn finalize(self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node)) !void {
pub fn finalize(
self: *ResolvedModule,
allocator: std.mem.Allocator,
list: *std.ArrayList(*Node),
own_list: *std.ArrayList(*Node),
) !void {
self.decls = try list.toOwnedSlice(allocator);
self.own_decls = try own_list.toOwnedSlice(allocator);
}
};
@@ -323,6 +399,7 @@ pub fn resolveImports(
var mod = ResolvedModule{
.path = file_path,
.decls = &.{},
.own_decls = &.{},
.scope = std.StringHashMap(void).init(allocator),
};
@@ -338,6 +415,11 @@ pub fn resolveImports(
const flat_decls = try flattenComptimeConditionals(allocator, root.data.root.decls, comptime_ctx);
var decl_list = std.ArrayList(*Node).empty;
var own_decl_list = std.ArrayList(*Node).empty;
// Name set spanning every decl already appended to `decl_list` — used
// by `mergeFlat` to dedupe across diamond imports now that `mod.scope`
// is non-transitive and can no longer serve as the dedup key.
var seen_in_list = std.StringHashMap(void).init(allocator);
for (flat_decls) |decl| {
if (decl.data == .c_import_decl) {
@@ -397,21 +479,23 @@ pub fn resolveImports(
};
ns_node.source_file = file_path;
try mod.scope.put(ns_name, {});
try seen_in_list.put(ns_name, {});
try decl_list.append(allocator, ns_node);
try own_decl_list.append(allocator, ns_node);
} else {
// Flat: add fn_decls directly + keep c_import_decl
for (result.fn_decls) |fd| {
fd.source_file = file_path;
_ = try mod.addDecl(allocator, &decl_list, fd);
_ = try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, fd);
}
decl.source_file = file_path;
_ = try mod.addDecl(allocator, &decl_list, decl);
_ = try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl);
}
continue;
}
if (decl.data != .import_decl) {
decl.source_file = file_path;
_ = try mod.addDecl(allocator, &decl_list, decl);
_ = try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl);
continue;
}
const imp = decl.data.import_decl;
@@ -473,13 +557,13 @@ pub fn resolveImports(
};
if (imp.name) |ns_name| {
try mod.addNamespace(allocator, &decl_list, ns_name, imported_mod, decl.span);
try mod.addNamespace(allocator, &decl_list, &own_decl_list, &seen_in_list, ns_name, imported_mod, decl.span);
} else {
try mod.mergeFlat(allocator, &decl_list, imported_mod);
try mod.mergeFlat(allocator, &decl_list, &seen_in_list, imported_mod);
}
}
try mod.finalize(allocator, &decl_list);
try mod.finalize(allocator, &decl_list, &own_decl_list);
return mod;
}
@@ -524,13 +608,20 @@ fn resolveDirectoryImport(
try chain.put(dir_path, {});
defer _ = chain.remove(dir_path);
// Merge all files into a combined module
// Merge all files into a combined module. From an importer's perspective
// a directory is one big module: the combined module's `own_decls` is
// the union of every file's `own_decls`, so flat-importing the directory
// exposes everything the files themselves authored — but not what those
// files transitively imported from outside the directory.
var combined = ResolvedModule{
.path = dir_path,
.decls = &.{},
.own_decls = &.{},
.scope = std.StringHashMap(void).init(allocator),
};
var decl_list = std.ArrayList(*Node).empty;
var own_decl_list = std.ArrayList(*Node).empty;
var seen_in_list = std.StringHashMap(void).init(allocator);
for (file_names.items) |file_name| {
const file_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir_path, file_name });
@@ -568,9 +659,34 @@ fn resolveDirectoryImport(
break :file_blk result;
};
try combined.mergeFlat(allocator, &decl_list, file_mod);
// Source-order matters: a file's own decls (e.g. `impl Foo` blocks)
// may reference types defined in OTHER files that THIS file imports.
// `file_mod.decls` already lists transitive-imported decls before
// the file's own decls (resolveImports processes `#import` lines in
// source order, and #imports usually come first), so iterating it
// directly preserves the scan order the lowering pass needs to
// register `Event` (a tagged_union) before `handle_event(e: *Event)`
// triggers the placeholder-struct fallback in `resolveTypeName`.
for (file_mod.decls) |decl| {
if (decl.data.declName()) |name| {
if (seen_in_list.contains(name)) continue;
try seen_in_list.put(name, {});
}
try decl_list.append(allocator, decl);
}
// Separately track which decls the directory `re-exports` to its
// flat-importers. Position in `own_decl_list` doesn't matter — it's
// only consumed by the importer-side visibility join (`isNameVisible`
// in lower.zig) which treats it as a set.
for (file_mod.own_decls) |decl| {
if (decl.data.declName()) |name| {
if (combined.scope.contains(name)) continue;
try combined.scope.put(name, {});
}
try own_decl_list.append(allocator, decl);
}
}
try combined.finalize(allocator, &decl_list);
try combined.finalize(allocator, &decl_list, &own_decl_list);
return combined;
}

View File

@@ -681,32 +681,40 @@ pub const LLVMEmitter = struct {
}
}
/// Initialize vtable globals with function pointer constants.
/// Must run after Pass 1 (function declarations) so func_map is populated.
/// Initialize vtable + aggregate-with-func_ref globals with function
/// pointer constants. Must run after Pass 1 (function declarations) so
/// func_map is populated — that's why these globals get a placeholder
/// initializer in `emitGlobals` and we fix them up here.
fn initVtableGlobals(self: *LLVMEmitter) void {
for (self.ir_mod.globals.items, 0..) |global, i| {
const iv = global.init_val orelse continue;
const func_ids = switch (iv) {
.vtable => |ids| ids,
else => continue,
};
const llvm_global = self.global_map.get(@intCast(i)) orelse continue;
const llvm_ty = self.toLLVMType(global.ty);
// Build constant struct of function pointers
var field_vals = std.ArrayList(c.LLVMValueRef).empty;
defer field_vals.deinit(self.alloc);
for (func_ids) |fid| {
const llvm_func = self.func_map.get(fid.index()) orelse {
field_vals.append(self.alloc, c.LLVMConstNull(self.cached_ptr)) catch unreachable;
continue;
};
field_vals.append(self.alloc, llvm_func) catch unreachable;
switch (iv) {
.vtable => |func_ids| {
var field_vals = std.ArrayList(c.LLVMValueRef).empty;
defer field_vals.deinit(self.alloc);
for (func_ids) |fid| {
const llvm_func = self.func_map.get(fid.index()) orelse {
field_vals.append(self.alloc, c.LLVMConstNull(self.cached_ptr)) catch unreachable;
continue;
};
field_vals.append(self.alloc, llvm_func) catch unreachable;
}
const init_val = c.LLVMConstNamedStruct(llvm_ty, field_vals.items.ptr, @intCast(field_vals.items.len));
c.LLVMSetInitializer(llvm_global, init_val);
c.LLVMSetGlobalConstant(llvm_global, 1);
},
.aggregate => |agg| {
// Re-emit. The first pass in `emitGlobals` already ran,
// but func_ref leaves resolved to null then (func_map
// wasn't populated yet). Now they resolve properly.
const init_val = self.emitConstAggregate(agg, llvm_ty);
c.LLVMSetInitializer(llvm_global, init_val);
},
else => continue,
}
const init_val = c.LLVMConstNamedStruct(llvm_ty, field_vals.items.ptr, @intCast(field_vals.items.len));
c.LLVMSetInitializer(llvm_global, init_val);
c.LLVMSetGlobalConstant(llvm_global, 1);
}
}
@@ -2241,39 +2249,6 @@ pub const LLVMEmitter = struct {
.call_builtin => |bi| {
// Builtins that map to libc functions or LLVM intrinsics
switch (bi.builtin) {
.malloc => {
const size = self.coerceArg(self.resolveRef(bi.args[0]), self.sizeType());
const malloc_fn = self.getOrDeclareMalloc();
var args = [_]c.LLVMValueRef{size};
self.mapRef(c.LLVMBuildCall2(self.builder, self.getMallocType(), malloc_fn, &args, 1, "malloc"));
},
.free => {
const ptr = self.resolveRef(bi.args[0]);
const free_fn = self.getOrDeclareFree();
var args = [_]c.LLVMValueRef{ptr};
_ = c.LLVMBuildCall2(self.builder, self.getFreeType(), free_fn, &args, 1, "");
self.advanceRefCounter();
},
.memcpy => {
const dst = self.resolveRef(bi.args[0]);
const src = self.resolveRef(bi.args[1]);
const len = self.coerceArg(self.resolveRef(bi.args[2]), self.sizeType());
const memcpy_fn = self.getOrDeclareMemcpy();
var args = [_]c.LLVMValueRef{ dst, src, len };
_ = c.LLVMBuildCall2(self.builder, self.getMemcpyType(), memcpy_fn, &args, 3, "");
self.advanceRefCounter();
},
.memset => {
const dst = self.resolveRef(bi.args[0]);
var val = self.resolveRef(bi.args[1]);
const len = self.coerceArg(self.resolveRef(bi.args[2]), self.sizeType());
// memset expects i32 for byte value — coerce width
val = self.coerceArg(val, self.cached_i32);
const memset_fn = self.getOrDeclareMemset();
var args = [_]c.LLVMValueRef{ dst, val, len };
_ = c.LLVMBuildCall2(self.builder, self.getMemsetType(), memset_fn, &args, 3, "");
self.advanceRefCounter();
},
.sqrt, .sin, .cos, .floor => {
const val = self.resolveRef(bi.args[0]);
const val_ty = c.LLVMTypeOf(val);
@@ -3699,6 +3674,7 @@ pub const LLVMEmitter = struct {
.boolean => |v| c.LLVMConstInt(elem_ty, @intFromBool(v), 0),
.string => |sid| self.emitConstStringGlobal(self.ir_mod.types.getString(sid)),
.aggregate => |inner| self.emitConstAggregate(inner, elem_ty),
.func_ref => |fid| self.func_map.get(fid.index()) orelse c.LLVMConstNull(elem_ty),
else => c.LLVMConstNull(elem_ty),
};
}

View File

@@ -352,11 +352,8 @@ pub const BuiltinId = enum(u16) {
cos,
floor,
size_of,
align_of,
cast,
malloc,
free,
memcpy,
memset,
type_of,
alloc,
dealloc,
@@ -519,5 +516,10 @@ pub const ConstantValue = union(enum) {
aggregate: []const ConstantValue,
/// Vtable constant: struct of function pointers, used for protocol vtable globals.
vtable: []const FuncId,
/// Function pointer leaf, for static initializers that include
/// function addresses inside nested aggregates (e.g. the inline
/// Allocator value `{ ctx, alloc_fn, dealloc_fn }` for the
/// process-wide default Context).
func_ref: FuncId,
};

View File

@@ -238,8 +238,11 @@ pub const Interpreter = struct {
.boolean => |b| @intFromBool(b),
.null_val => 0,
.heap_ptr => |hp| blk: {
const mem = self.heapSlice(hp) orelse return error.TypeError;
break :blk @intFromPtr(mem.ptr) + hp.offset;
// `heapSlice` returns the slice already advanced by `hp.offset`,
// so its `.ptr` IS the offset address. Adding `hp.offset` again
// double-counts and lands the foreign call past the buffer end.
_ = self.heapSlice(hp) orelse return error.TypeError;
break :blk @intFromPtr(self.heap.items[hp.id].ptr) + hp.offset;
},
.string => |s| blk: {
const buf = try self.alloc.alloc(u8, s.len + 1);
@@ -1315,6 +1318,7 @@ pub const Interpreter = struct {
}
return .{ .aggregate = fields };
},
.func_ref => |fid| .{ .func_ref = fid },
};
}
@@ -1401,56 +1405,6 @@ pub const Interpreter = struct {
fn execBuiltinInner(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame) InterpError!ExecResult {
switch (bi.builtin) {
.malloc => {
const size_val = frame.getRef(bi.args[0]);
const size: usize = @intCast(size_val.asInt() orelse return error.TypeError);
const hp = self.heapAlloc(size);
return .{ .value = .{ .heap_ptr = hp } };
},
.free => {
const ptr = frame.getRef(bi.args[0]);
switch (ptr) {
.heap_ptr => |hp| self.heapFree(hp),
else => {},
}
return .{ .value = .void_val };
},
.memcpy => {
const dst = frame.getRef(bi.args[0]);
const src = frame.getRef(bi.args[1]);
const len_val = frame.getRef(bi.args[2]);
const len: usize = @intCast(len_val.asInt() orelse return error.TypeError);
const dst_hp = switch (dst) {
.heap_ptr => |hp| hp,
else => return error.CannotEvalComptime,
};
const src_bytes: []const u8 = switch (src) {
.heap_ptr => |hp| self.heapSlice(hp) orelse return error.CannotEvalComptime,
.string => |s| s,
// Raw host address (e.g. a `*u8` returned by a foreign
// call like getenv). Read `len` bytes across the FFI
// boundary into the sx-managed dst.
.int => |addr| blk: {
const raw: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(addr)));
break :blk raw[0..len];
},
else => return error.CannotEvalComptime,
};
self.heapMemcpy(dst_hp, src_bytes, len);
return .{ .value = .{ .heap_ptr = dst_hp } };
},
.memset => {
const dst = frame.getRef(bi.args[0]);
const val = frame.getRef(bi.args[1]);
const len_val = frame.getRef(bi.args[2]);
const byte: u8 = @intCast(@as(u64, @bitCast(val.asInt() orelse return error.TypeError)) & 0xFF);
const len: usize = @intCast(len_val.asInt() orelse return error.TypeError);
switch (dst) {
.heap_ptr => |hp| self.heapMemset(hp, byte, len),
else => {},
}
return .{ .value = .void_val };
},
.out => {
const str_val = frame.getRef(bi.args[0]);
if (str_val.asString(self)) |s| {
@@ -1462,6 +1416,9 @@ pub const Interpreter = struct {
// Return a default size (8 bytes for most types)
return .{ .value = .{ .int = 8 } };
},
.align_of => {
return .{ .value = .{ .int = 8 } };
},
.sqrt => {
const val = frame.getRef(bi.args[0]);
const f = val.asFloat() orelse return error.TypeError;

View File

@@ -161,6 +161,10 @@ pub const Lowering = struct {
name: []const u8,
param_types: []const TypeId, // excluding self
ret_type: TypeId,
// True when the AST return type was `Self` (encoded here as *void).
// Lets the dispatcher distinguish Self-disguised-as-*void (auto-unbox
// on the caller side) from a literal `-> *void` (return as-is).
ret_is_self: bool = false,
};
/// One impl block for a parameterised protocol (e.g. `impl Into(Block) for Closure() -> void`).
@@ -214,6 +218,12 @@ pub const Lowering = struct {
self.scanDecls(decls);
// Pass 1b: inject compile-time constants (OS, ARCH, POINTER_SIZE) from target config
self.injectComptimeConstants();
// Pass 1c: emit the process-wide default Context global, statically
// initialised to a CAllocator-backed Allocator value. Used by FFI
// wrappers in Step 4 and by the interp's `callWithDefaultContext`
// entry. Only fires when the program imports `std.sx` (so Context +
// Allocator + CAllocator are all registered).
self.emitDefaultContextGlobal();
// Pass 2: lower main (and comptime side-effects)
self.lowerMainAndComptime(decls);
// Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered
@@ -418,10 +428,32 @@ pub const Lowering = struct {
} else if (cd.value.data == .union_decl) {
// Register plain union types in the type table
_ = type_bridge.resolveAstType(cd.value, &self.module.types);
} else if (cd.value.data == .type_expr) {
// Type alias: MyFloat :: f64; → register MyFloat as alias for f64
} else if (cd.value.data == .type_expr or
cd.value.data == .pointer_type_expr or
cd.value.data == .many_pointer_type_expr or
cd.value.data == .array_type_expr or
cd.value.data == .slice_type_expr or
cd.value.data == .optional_type_expr or
cd.value.data == .function_type_expr)
{
// Type alias: MyFloat :: f64; Ptr :: *u8; Cb :: (s32) -> s32;
const target_ty = type_bridge.resolveAstType(cd.value, &self.module.types);
self.type_alias_map.put(cd.name, target_ty) catch {};
} else if (cd.value.data == .identifier) {
// Identifier-RHS alias: MyAlias :: MyInt; WideAlias :: Wide;
// Chase through type_alias_map, then look up named types
// in the table. Forward references resolve lazily because
// the .identifier branch of resolveTypeArg also consults
// type_alias_map at use time.
const rhs_name = cd.value.data.identifier.name;
if (self.type_alias_map.get(rhs_name)) |chained| {
self.type_alias_map.put(cd.name, chained) catch {};
} else {
const name_id = self.module.types.internString(rhs_name);
if (self.module.types.findByName(name_id)) |tid| {
self.type_alias_map.put(cd.name, tid) catch {};
}
}
}
// Handle generic struct instantiation: Vec3 :: Vec(3, f32)
// Parser produces a .call node for these (not parameterized_type_expr)
@@ -723,11 +755,38 @@ pub const Lowering = struct {
// Only restrict C import fn_decls: foreign_expr with no library_ref
if (fd.body.data != .foreign_expr) return true;
if (fd.body.data.foreign_expr.library_ref != null) return true;
// It's a C import fn_decl — check module scope
return self.isNameVisible(fn_name);
}
/// Non-transitive `#import` visibility check for top-level decls.
///
/// `module_scopes[F]` holds ONLY the names authored in file F (plus its
/// namespace aliases). Cross-module visibility is joined here at query
/// time by walking each direct flat-import edge in `import_graph` — a
/// name is visible from F when it's authored in F or in any module F
/// directly `#import`s. Doing the join here (instead of pre-merging in
/// `resolveImports`) lets cyclic imports like std.sx ↔ allocators.sx
/// still resolve, since the cycle's skipped edge is still recorded in
/// `import_graph` and the partner's scope is filled in by the time
/// lowering queries it.
///
/// Falls open when the scoping infrastructure isn't wired (comptime
/// callers, directory imports without main_file, etc.). The caller is
/// responsible for restricting the call to names that ARE known
/// top-level decls; otherwise every local variable would be policed.
fn isNameVisible(self: *Lowering, name: []const u8) bool {
const scopes = self.module_scopes orelse return true;
const source = self.current_source_file orelse return true;
const scope = scopes.get(source) orelse return true;
return scope.contains(fn_name);
const own_scope = scopes.get(source) orelse return true;
if (own_scope.contains(name)) return true;
const graph = self.import_graph orelse return true;
const direct = graph.get(source) orelse return true;
var it = direct.iterator();
while (it.next()) |kv| {
const dep = scopes.get(kv.key_ptr.*) orelse continue;
if (dep.contains(name)) return true;
}
return false;
}
/// Lazily lower a function body on demand. Called when lowerCall can't find
@@ -737,8 +796,21 @@ pub const Lowering = struct {
if (self.lowered_functions.contains(name)) return;
// No AST? (builtins, foreign functions, or imported functions not in this file)
const fd = self.fn_ast_map.get(name) orelse return;
// Check builtin/foreign/generic — these stay as extern stubs
if (fd.body.data == .builtin_expr or fd.body.data == .foreign_expr or fd.body.data == .compiler_expr) return;
// Foreign declarations stay as extern stubs but need to be REGISTERED
// in the current module so callers get a real FuncId. Without this,
// a comptime-lowered function (e.g. `concat` from std.sx pulled into
// a fresh ct_module via `evalComptimeString`) emits `.call` against a
// FuncId that doesn't exist locally; the interp can't find the
// foreign target and silently no-ops instead of dispatching to libc.
if (fd.body.data == .foreign_expr) {
if (self.resolveFuncByName(name) == null) {
self.declareFunction(fd, name);
self.lowered_functions.put(name, {}) catch {};
}
return;
}
// Builtins / #compiler bodies stay as compiler-handled — no extern stub needed.
if (fd.body.data == .builtin_expr or fd.body.data == .compiler_expr) return;
if (fd.type_params.len > 0) return; // generics handled by monomorphization (Step 3.13)
// Defer functions with type-category matches until all types are registered.
@@ -1715,12 +1787,29 @@ pub const Lowering = struct {
}
// Check module-level value constants (e.g. AF_INET :s32: 2)
if (self.module_const_map.get(id.name)) |ci| {
if (!self.isNameVisible(id.name)) {
if (self.diagnostics) |d|
d.addFmt(.err, node.span, "'{s}' is not visible; #import the module that declares it", .{id.name});
break :blk self.emitError(id.name, node.span);
}
break :blk self.emitModuleConst(ci);
}
// Check if it's a function name — produce function pointer reference
// Resolve mangled name for block-local functions
const eff_fn_name = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
if (self.fn_ast_map.contains(eff_fn_name)) {
// Visibility check only for user-typed bare names (id.name
// == eff_fn_name) without a UFCS alias. Mangled local-
// scope names and UFCS rewrites are compiler indirections
// and stay exempt.
if (std.mem.eql(u8, eff_fn_name, id.name) and
self.ufcs_alias_map.get(id.name) == null and
!self.isNameVisible(eff_fn_name))
{
if (self.diagnostics) |d|
d.addFmt(.err, node.span, "'{s}' is not visible; #import the module that declares it", .{eff_fn_name});
break :blk self.emitError(eff_fn_name, node.span);
}
// Type-as-value: if target is Any (Type variable), produce a type name string
if (self.target_type == .any) {
const fd = self.fn_ast_map.get(eff_fn_name).?;
@@ -4423,6 +4512,19 @@ pub const Lowering = struct {
d.addFmt(.err, c.callee.span, "C function '{s}' not visible; add #import for the module that declares it", .{eff_name});
return Ref.none;
}
// Non-transitive `#import` visibility check. Apply only when the
// user-typed name resolved as-is to a top-level fn — local-scope
// mangling (eff_name != id_name) and UFCS alias rewriting are
// compiler indirections and stay exempt.
if (std.mem.eql(u8, eff_name, id_name) and
self.ufcs_alias_map.get(id_name) == null and
self.fn_ast_map.contains(eff_name) and
!self.isNameVisible(eff_name))
{
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is not visible; #import the module that declares it", .{eff_name});
return Ref.none;
}
if (self.fn_ast_map.get(eff_name)) |fd| {
if (self.current_match_tags) |tags| {
if (tags.len > 0 and self.hasCastWithRuntimeType(c)) {
@@ -4608,19 +4710,8 @@ pub const Lowering = struct {
}
// Check builtins first (these are handled natively by interpreter and emitter)
if (resolveBuiltin(id.name)) |bid| {
// free(protocol_value) → extract ctx (field 0) and free it
if (bid == .free and args.items.len == 1) {
const arg_ty = self.builder.getRefType(args.items[0]);
if (self.getProtocolInfo(arg_ty) != null) {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = args.items[0], .field_index = 0 } }, void_ptr_ty);
return self.builder.emit(.{ .heap_free = .{ .operand = ctx_ref } }, .void);
}
}
const ret_ty: TypeId = switch (bid) {
.malloc => .s64, // pointer
.size_of => .s64,
.memcpy, .memset => .s64,
.size_of, .align_of => .s64,
.sqrt, .sin, .cos, .floor => blk: {
// Math builtins: return type matches argument type ($T -> T)
if (c.args.len > 0) {
@@ -5041,6 +5132,53 @@ pub const Lowering = struct {
}
}
// Generic method on a non-template struct: `obj.method($T, ...)`
// or inferred form `obj.method(val)` where val's type pins $T.
if (self.fn_ast_map.get(qualified)) |gen_fd| {
if (gen_fd.type_params.len > 0 and gen_fd.body.data != .compiler_expr) {
// Effective AST args: prepend receiver so positions
// line up with fd.params (which has self at index 0).
var eff_args = std.ArrayList(*const Node).empty;
defer eff_args.deinit(self.alloc);
eff_args.append(self.alloc, effective_obj_node) catch unreachable;
for (c.args) |a| eff_args.append(self.alloc, a) catch unreachable;
var gbindings = self.buildTypeBindings(gen_fd, eff_args.items);
defer gbindings.deinit();
const gmangled = self.mangleGenericName(qualified, gen_fd, &gbindings);
if (!self.lowered_functions.contains(gmangled)) {
self.monomorphizeFunction(gen_fd, gmangled, &gbindings);
}
if (self.resolveFuncByName(gmangled)) |gfid| {
const gfunc = &self.module.functions.items[@intFromEnum(gfid)];
const gret_ty = gfunc.ret;
const gparams = gfunc.params;
// Strip type-decl slots from method_args. method_args[0] is the
// receiver (corresponds to fd.params[0] = self, never a type decl).
// Walk fd.params[1..], advance arg_idx through method_args[1..].
var gvalue_args = std.ArrayList(Ref).empty;
defer gvalue_args.deinit(self.alloc);
gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable;
const types_explicit = method_args.items.len == gen_fd.params.len;
var arg_idx: usize = 1;
for (gen_fd.params[1..]) |p| {
if (isTypeParamDecl(&p, gen_fd.type_params)) {
if (types_explicit) arg_idx += 1;
continue;
}
if (arg_idx < method_args.items.len) {
gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable;
}
arg_idx += 1;
}
self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty);
self.coerceCallArgs(gvalue_args.items, gparams);
return self.builder.call(gfid, gvalue_args.items, gret_ty);
}
}
}
// Try non-generic qualified method
if (self.fn_ast_map.get(qualified)) |fd| {
if (!self.lowered_functions.contains(qualified)) {
@@ -5134,6 +5272,49 @@ pub const Lowering = struct {
}
}
/// Emit `context.allocator.alloc(size)` dispatch — used by internal
/// compiler-driven heap copies (e.g. the `xx value` protocol-erasure
/// path in `buildProtocolValue`). Routes through whatever allocator is
/// currently installed in `context`, so a surrounding
/// `push Context.{ allocator = my_alloc, ... }` actually backs every
/// allocation including the ones the compiler inserts.
///
/// Falls back to `.heap_alloc` (libc malloc) only when the `context`
/// global hasn't been registered (programs that don't `#import
/// "modules/std.sx"`). All standard sx code imports std.sx via
/// allocators.sx, so the fallback exists strictly for the bootstrapping
/// edge case.
fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref {
const ctx_gi = self.global_names.get("context") orelse {
return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
};
const ctx_ty_info = self.module.types.get(ctx_gi.ty);
if (ctx_ty_info != .@"struct" or ctx_ty_info.@"struct".fields.len < 1) {
return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
}
const allocator_ty = ctx_ty_info.@"struct".fields[0].ty;
const ctx = self.builder.emit(.{ .global_get = ctx_gi.id }, ctx_gi.ty);
const allocator = self.builder.structGet(ctx, 0, allocator_ty);
// #inline Allocator protocol layout: { ctx, alloc_fn_ptr, dealloc_fn_ptr }.
// field 0 = receiver ctx, field 1 = alloc fn-ptr.
const alloc_ctx = self.builder.structGet(allocator, 0, void_ptr_ty);
const fn_ptr = self.builder.structGet(allocator, 1, void_ptr_ty);
const args = self.alloc.dupe(Ref, &.{ alloc_ctx, size_ref }) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{
.callee = fn_ptr,
.args = args,
} }, void_ptr_ty);
}
/// Emit a call to a foreign-declared function looked up by name.
/// Used for the compiler-internal byte-copy in the protocol-erasure
/// heap path and the closure env-copy path, both of which need
/// libc `memcpy` after the `#builtin` form was dropped.
fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref {
const fid = self.resolveFuncByName(name) orelse @panic("foreign symbol missing — std.sx not imported?");
return self.builder.call(fid, args, ret_ty);
}
/// Pattern-match `context.allocator.alloc(size)` → heap_alloc,
/// `context.allocator.dealloc(ptr)` → heap_free.
fn matchContextAllocCall(self: *Lowering, fa: ast.FieldAccess, call_args: []const Ref) ?Ref {
@@ -5177,11 +5358,8 @@ pub const Lowering = struct {
.{ "cos", inst_mod.BuiltinId.cos },
.{ "floor", inst_mod.BuiltinId.floor },
.{ "size_of", inst_mod.BuiltinId.size_of },
.{ "align_of", inst_mod.BuiltinId.align_of },
.{ "cast", inst_mod.BuiltinId.cast },
.{ "malloc", inst_mod.BuiltinId.malloc },
.{ "free", inst_mod.BuiltinId.free },
.{ "memcpy", inst_mod.BuiltinId.memcpy },
.{ "memset", inst_mod.BuiltinId.memset },
};
inline for (builtins) |entry| {
if (std.mem.eql(u8, name, entry[0])) return entry[1];
@@ -5349,11 +5527,7 @@ pub const Lowering = struct {
const env_byte_size_inner = self.computeEnvSize(capture_list);
const env_size_val = self.builder.constInt(@intCast(env_byte_size_inner), .s64);
// memcpy(local_alloca, env_param, size)
const cp_args = self.alloc.dupe(Ref, &.{ env_local, env_param_ref, env_size_val }) catch unreachable;
_ = self.builder.emit(.{ .call_builtin = .{
.builtin = inst_mod.BuiltinId.memcpy,
.args = cp_args,
} }, self.module.types.ptrTo(.void));
_ = self.callForeign("memcpy", &.{ env_local, env_param_ref, env_size_val }, self.module.types.ptrTo(.void));
for (capture_list, 0..) |cap, i| {
// GEP into env struct to get field pointer
@@ -5440,11 +5614,7 @@ pub const Lowering = struct {
const ptr_void = self.module.types.ptrTo(.void);
const env_heap = self.builder.emit(.{ .heap_alloc = .{ .operand = env_size } }, ptr_void);
// memcpy(heap, stack_alloca, size)
const args = self.alloc.dupe(Ref, &.{ env_heap, env_local, env_size }) catch unreachable;
_ = self.builder.emit(.{ .call_builtin = .{
.builtin = inst_mod.BuiltinId.memcpy,
.args = args,
} }, ptr_void);
_ = self.callForeign("memcpy", &.{ env_heap, env_local, env_size }, ptr_void);
return self.builder.closureCreate(func_id, env_heap, closure_ty);
} else {
@@ -6369,31 +6539,26 @@ pub const Lowering = struct {
// ── Generic monomorphization ──────────────────────────────────
/// Lower a call to a generic function by monomorphizing it with inferred type arguments.
fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
// Infer type param bindings from call arguments
/// Build `tp.name -> TypeId` bindings for a generic call.
/// `args_ast` must be parallel to `fd.params`; for dot-calls the caller
/// prepends the receiver's AST node so positions align with `fd.params[0] = self`.
/// Caller owns the returned map and must call `.deinit()`.
fn buildTypeBindings(
self: *Lowering,
fd: *const ast.FnDecl,
args_ast: []const *const Node,
) std.StringHashMap(TypeId) {
var bindings = std.StringHashMap(TypeId).init(self.alloc);
defer bindings.deinit();
// Determine if type args are passed explicitly:
// If call_node.args.len == fd.params.len, the caller passed type args explicitly
// (e.g., are_equal(Point, p1, p2)). Otherwise, types are inferred from value args
// (e.g., are_equal(p1, p2)).
const types_passed_explicitly = call_node.args.len == fd.params.len;
const types_passed_explicitly = args_ast.len == fd.params.len;
for (fd.type_params) |tp| {
var found = false;
// Strategy 1: Direct type param declaration ($T: Type)
// The param whose name matches the type param IS the declaration.
// The call arg at that position is a type expression — resolve it directly.
// Only applies when type args are passed explicitly in the call.
// Strategy 1: explicit — the param whose name matches `tp.name` IS
// the `$T: Type` declaration; the arg at that position is a type expression.
if (types_passed_explicitly) {
for (fd.params, 0..) |param, pi| {
if (std.mem.eql(u8, param.name, tp.name)) {
// This param IS the type param declaration
if (pi < call_node.args.len) {
const ty = self.resolveTypeArg(call_node.args[pi]);
if (pi < args_ast.len and type_bridge.isTypeShapedAstNode(args_ast[pi], &self.module.types)) {
const ty = self.resolveTypeArg(args_ast[pi]);
bindings.put(tp.name, ty) catch {};
found = true;
}
@@ -6402,11 +6567,8 @@ pub const Lowering = struct {
}
}
if (found) continue;
// Strategy 2: Infer from params that USE the type param (e.g., a: $T, b: T, items: []$T)
// Check ALL params whose type matches the type param name, pick widest type.
// When types are inferred (not explicit), use a separate arg index that
// skips type param declarations to correctly map params to call args.
// Strategy 2: infer from value params that USE the type param
// (e.g. a: $T, b: T, items: []$T). Pick widest type across matches.
var inferred_ty: ?TypeId = null;
var s2_arg_idx: usize = 0;
for (fd.params) |param| {
@@ -6420,8 +6582,8 @@ pub const Lowering = struct {
}
const matched = self.matchTypeParam(param.type_expr, tp.name);
if (matched) {
if (s2_arg_idx < call_node.args.len) {
const arg_ty = self.inferExprType(call_node.args[s2_arg_idx]);
if (s2_arg_idx < args_ast.len) {
const arg_ty = self.inferExprType(args_ast[s2_arg_idx]);
const extracted = self.extractTypeParam(param.type_expr, arg_ty, tp.name);
if (extracted) |ety| {
if (inferred_ty) |prev| {
@@ -6441,8 +6603,17 @@ pub const Lowering = struct {
bindings.put(tp.name, ty) catch {};
}
}
return bindings;
}
// Build mangled name: "func_name__Type1_Type2"
/// Mangle a generic call site into "base__Type1_Type2".
/// Returns a heap-allocated string owned by self.alloc.
fn mangleGenericName(
self: *Lowering,
base_name: []const u8,
fd: *const ast.FnDecl,
bindings: *const std.StringHashMap(TypeId),
) []const u8 {
var mangled_buf: [256]u8 = undefined;
var mangled_len: usize = 0;
for (base_name) |ch| {
@@ -6452,14 +6623,12 @@ pub const Lowering = struct {
}
}
for (fd.type_params) |tp| {
// Append separator
for ("__") |ch| {
if (mangled_len < mangled_buf.len) {
mangled_buf[mangled_len] = ch;
mangled_len += 1;
}
}
// Append type name
const ty = bindings.get(tp.name) orelse .s64;
const type_name_str = self.mangleTypeName(ty);
for (type_name_str) |ch| {
@@ -6469,27 +6638,31 @@ pub const Lowering = struct {
}
}
}
const mangled_name = mangled_buf[0..mangled_len];
return self.alloc.dupe(u8, mangled_buf[0..mangled_len]) catch base_name;
}
/// Lower a call to a generic function by monomorphizing it with inferred type arguments.
fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
var bindings = self.buildTypeBindings(fd, call_node.args);
defer bindings.deinit();
const types_passed_explicitly = call_node.args.len == fd.params.len;
const mangled_name = self.mangleGenericName(base_name, fd, &bindings);
// Check cache
if (!self.lowered_functions.contains(mangled_name)) {
// Monomorphize: create a new function with the mangled name and lower with type bindings
self.monomorphizeFunction(fd, mangled_name, &bindings);
}
// Resolve the monomorphized function and call it (stripping type args)
if (self.resolveFuncByName(mangled_name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// Build value-only args (skip type param declaration args)
// Use separate index for lowered_args since type params don't consume call args
var value_args = std.ArrayList(Ref).empty;
defer value_args.deinit(self.alloc);
var arg_idx: usize = 0;
for (fd.params) |p| {
if (isTypeParamDecl(&p, fd.type_params)) {
// Only skip in lowered_args if types were passed explicitly in the call
if (types_passed_explicitly) arg_idx += 1;
continue;
}
@@ -6894,6 +7067,11 @@ pub const Lowering = struct {
const size: i64 = @intCast(self.typeSizeBytes(ty));
return self.builder.constInt(size, .s64);
}
if (std.mem.eql(u8, name, "align_of")) {
const ty = self.resolveTypeArg(c.args[0]);
const a: i64 = @intCast(self.module.types.typeAlignBytes(ty));
return self.builder.constInt(a, .s64);
}
if (std.mem.eql(u8, name, "field_count")) {
// field_count(T) → const_int(N)
const ty = self.resolveTypeArg(c.args[0]);
@@ -7019,9 +7197,13 @@ pub const Lowering = struct {
if (self.type_bindings) |tb| {
if (tb.get(id.name)) |ty| return ty;
}
// Try as a named type by name (resolveAstType doesn't handle .identifier)
if (self.type_alias_map.get(id.name)) |alias_ty| return alias_ty;
const name_id = self.module.types.internString(id.name);
return self.module.types.findByName(name_id) orelse .s64;
if (self.module.types.findByName(name_id)) |t| return t;
if (self.diagnostics) |diags| {
diags.addFmt(.err, node.span, "unresolved type: '{s}'", .{id.name});
}
return .void;
},
.type_expr => |te| {
if (self.type_alias_map.get(te.name)) |alias_ty| return alias_ty;
@@ -7031,6 +7213,14 @@ pub const Lowering = struct {
// Handle type constructor calls: size_of(Sx(f32)), size_of(Complex(u32))
return self.resolveTypeCallWithBindings(&cl);
},
.pointer_type_expr,
.many_pointer_type_expr,
.array_type_expr,
.slice_type_expr,
.optional_type_expr,
.function_type_expr,
.tuple_literal,
=> return type_bridge.resolveAstType(node, &self.module.types),
else => return .s64,
}
}
@@ -7498,6 +7688,41 @@ pub const Lowering = struct {
// skipping the first param (self) since it's prepended later.
if (c.callee.data == .field_access) {
const fa = c.callee.data.field_access;
// Namespace/static call: `Type.method(args)` where `Type` is a type
// identifier (not a value in scope). Args correspond to ALL params
// — no self prepend — so target_type for arg lowering must include
// the leading param. Skipping it would lose the protocol context
// for `xx ptr` inline-cast args.
if (fa.object.data == .identifier) {
const obj_name = fa.object.data.identifier.name;
const is_value = blk: {
if (self.scope) |scope| {
if (scope.lookup(obj_name) != null) break :blk true;
}
if (self.global_names.contains(obj_name)) break :blk true;
break :blk false;
};
if (!is_value) {
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ obj_name, fa.field }) catch return &.{};
if (self.resolveFuncByName(qualified)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
var types_list = std.ArrayList(TypeId).empty;
for (func.params) |p| {
types_list.append(self.alloc, p.ty) catch unreachable;
}
return types_list.items;
}
if (self.fn_ast_map.get(qualified)) |fd| {
var types_list = std.ArrayList(TypeId).empty;
for (fd.params) |p| {
types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
}
return types_list.items;
}
}
}
const obj_ty = self.inferExprType(fa.object);
// Protocol-typed receiver: look up the method on the protocol decl. The
// protocol's ProtocolMethodInfo.param_types already excludes self.
@@ -8511,9 +8736,11 @@ pub const Lowering = struct {
};
ptypes.append(self.alloc, pty) catch unreachable;
}
var ret_is_self = false;
const ret = if (method.return_type) |rt| blk: {
if (rt.data == .type_expr) {
if (std.mem.eql(u8, rt.data.type_expr.name, "Self")) {
ret_is_self = true;
break :blk void_ptr_ty;
}
if (self.type_alias_map.get(rt.data.type_expr.name)) |aliased| {
@@ -8526,6 +8753,7 @@ pub const Lowering = struct {
.name = method.name,
.param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable,
.ret_type = ret,
.ret_is_self = ret_is_self,
}) catch unreachable;
}
self.protocol_decl_map.put(pd.name, .{
@@ -8799,6 +9027,54 @@ pub const Lowering = struct {
return owned;
}
/// Emit the process-wide default Context as an LLVM static constant.
///
/// @__sx_default_context = internal constant %Context {
/// %Allocator { ptr null,
/// ptr @__thunk_CAllocator_Allocator_alloc,
/// ptr @__thunk_CAllocator_Allocator_dealloc },
/// ptr null
/// }
///
/// Used by FFI inbound wrappers (Step 4) and the interp's default-
/// context call entry (Step 7). Only emitted when the program imports
/// `std.sx` — without that, Context / Allocator / CAllocator aren't
/// registered and the global has no purpose.
fn emitDefaultContextGlobal(self: *Lowering) void {
const tbl = &self.module.types;
const ctx_name_id = tbl.internString("Context");
const ctx_ty = tbl.findByName(ctx_name_id) orelse return;
if (tbl.findByName(tbl.internString("Allocator")) == null) return;
if (tbl.findByName(tbl.internString("CAllocator")) == null) return;
// Force the CAllocator → Allocator thunks to exist so we can
// reference them by FuncId in the static initializer.
const thunks = self.getOrCreateThunks("Allocator", "CAllocator");
if (thunks.len < 2) return;
// Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void }
// CAllocator is stateless, so ctx is null.
const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return;
alloc_fields[0] = .null_val;
alloc_fields[1] = .{ .func_ref = thunks[0] };
alloc_fields[2] = .{ .func_ref = thunks[1] };
// Context value: { allocator: Allocator, data: *void }
const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return;
ctx_fields[0] = .{ .aggregate = alloc_fields };
ctx_fields[1] = .null_val;
const global_name = "__sx_default_context";
const global_name_id = tbl.internString(global_name);
const gid = self.module.addGlobal(.{
.name = global_name_id,
.ty = ctx_ty,
.init_val = .{ .aggregate = ctx_fields },
.is_const = true,
});
self.global_names.put(global_name, .{ .id = gid, .ty = ctx_ty }) catch {};
}
/// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret
/// The thunk calls ConcreteType.method(ctx, args...).
fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId {
@@ -8925,12 +9201,8 @@ pub const Lowering = struct {
if (heap_copy) {
const concrete_size = self.module.types.typeSizeBytes(concrete_ty);
const size_ref = self.builder.constInt(@intCast(concrete_size), .s64);
const heap_ptr = self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
const memcpy_args = self.alloc.dupe(Ref, &.{ heap_ptr, concrete_ptr, size_ref }) catch unreachable;
_ = self.builder.emit(.{ .call_builtin = .{
.builtin = inst_mod.BuiltinId.memcpy,
.args = memcpy_args,
} }, void_ptr_ty);
const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty);
_ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty);
ctx_ptr = heap_ptr;
}
@@ -9055,12 +9327,11 @@ pub const Lowering = struct {
const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable;
const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type);
// If protocol method returns *void (Self) and the caller expects a value type,
// unbox: load the concrete value from the returned pointer. Real pointer
// returns (declared `-> *T` for non-Self T) are NOT auto-loaded — the
// pointee may be a single byte and reading `sizeof(target)` past it
// segfaults. Self is encoded as `*void`, so test against that exact type.
if (mi.ret_type == void_ptr) {
// If the protocol method was declared `-> Self` (encoded here as *void)
// and the caller expects a value type, unbox: load the concrete value
// from the returned pointer. A literal `-> *void` return is NOT
// auto-loaded — it's a real pointer whose pointee size we don't know.
if (mi.ret_is_self) {
if (self.target_type) |target| {
const target_info = self.module.types.get(target);
if (target_info != .pointer) {
@@ -9149,7 +9420,7 @@ pub const Lowering = struct {
}
break :blk TypeId.f64;
},
.size_of, .malloc => .s64,
.size_of, .align_of => .s64,
.cast => if (c.args.len > 0) self.resolveTypeArg(c.args[0]) else .s64,
else => .s64,
};
@@ -9376,15 +9647,17 @@ pub const Lowering = struct {
defer tmp_bindings.deinit();
for (fd.type_params) |tp| {
// Strategy 1: direct type param decl ($T: Type) — param.name == tp.name
// Strategy 1: direct type param decl ($T: Type) — param.name == tp.name.
// Only fires when the caller actually supplied a type expression at
// that position; otherwise fall through to value-based inference.
var found = false;
for (fd.params, 0..) |param, pi| {
if (std.mem.eql(u8, param.name, tp.name)) {
if (pi < c.args.len) {
if (pi < c.args.len and type_bridge.isTypeShapedAstNode(c.args[pi], &self.module.types)) {
const ty = self.resolveTypeArg(c.args[pi]);
tmp_bindings.put(tp.name, ty) catch {};
found = true;
}
found = true;
break;
}
}
@@ -9482,6 +9755,21 @@ pub const Lowering = struct {
return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty);
}
// Protocol → pointer: recover the typed ctx pointer (field 0).
// The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
// `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
if (self.getProtocolInfo(src_ty)) |_| {
if (!dst_ty.isBuiltin()) {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .pointer) {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
if (dst_ty == void_ptr_ty) return ctx_ref;
return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
}
}
}
const result = self.coerceToType(operand, src_ty, dst_ty);
// User-space fallback via `impl Into(Target) for Source`. Only fires
@@ -9900,7 +10188,26 @@ pub const Lowering = struct {
fn emitError(self: *Lowering, name: []const u8, span: ?ast.Span) Ref {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "unresolved: '{s}'", .{name});
// The literal message carries the lowering's `current_source_file`
// and enclosing function name. The diagnostic renderer's
// `source_file` -> `file:line:col` prefix can drift when a span is
// offset into one source but the diagnostic falls back to another
// (e.g. synthetic AST nodes inserted from `#insert` take their
// span from the call site, not from the string being inserted).
// Embedding the file + function in the message means a
// misattributed span can never hide WHERE the lookup actually
// failed. Setting SX_TRACE_UNRESOLVED=1 also dumps a Zig stack
// trace at the emit site to surface the calling lowering path.
const sf = self.current_source_file orelse "<unknown>";
const fn_name: []const u8 = if (self.builder.func) |fid|
self.module.types.getString(self.module.functions.items[@intFromEnum(fid)].name)
else
"<top-level>";
if (std.c.getenv("SX_TRACE_UNRESOLVED") != null) {
std.debug.print("\n== unresolved '{s}' (in {s} fn {s}) ==\n", .{ name, sf, fn_name });
std.debug.dumpCurrentStackTrace(.{ .first_address = @returnAddress() });
}
diags.addFmt(.err, span, "unresolved '{s}' (in {s} fn {s})", .{ name, sf, fn_name });
}
return self.emitPlaceholder(name);
}

View File

@@ -516,6 +516,7 @@ fn writeConstant(val: ConstantValue, writer: Writer) !void {
.zeroinit => try writer.writeAll("zeroinit"),
.aggregate => try writer.writeAll("{...}"),
.vtable => try writer.writeAll("vtable{...}"),
.func_ref => |fid| try writer.print("func_ref(#{d})", .{fid.index()}),
}
}

View File

@@ -26,6 +26,7 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable) TypeId {
.function_type_expr => |ft| resolveFunctionType(&ft, table),
.closure_type_expr => |ct| resolveClosureType(&ct, table),
.tuple_type_expr => |tt| resolveTupleType(&tt, table),
.tuple_literal => |tl| resolveTupleLiteralAsType(&tl, table),
.parameterized_type_expr => |pt| resolveParameterizedType(&pt, table),
.inferred_type => .s64, // inferred — default until we have type inference
// Inline type declarations (used as field types)
@@ -299,6 +300,62 @@ fn resolveTupleType(tt: *const ast.TupleTypeExpr, table: *TypeTable) TypeId {
} });
}
// Treat a tuple value literal as the corresponding tuple TYPE — valid only when
// every element is itself a type expression. Non-type elements report a clear
// diagnostic and degrade to .s64 for that slot (which the snapshot will catch).
fn resolveTupleLiteralAsType(tl: *const ast.TupleLiteral, table: *TypeTable) TypeId {
const alloc = table.alloc;
var field_ids = std.ArrayList(TypeId).empty;
var name_ids_list = std.ArrayList(StringId).empty;
var any_named = false;
for (tl.elements) |el| {
if (!isTypeShapedAstNode(el.value, table)) {
std.debug.print("type_bridge: tuple literal element is not a type (tag={s}) — cannot use as tuple type\n", .{@tagName(el.value.data)});
field_ids.append(alloc, .s64) catch unreachable;
} else {
field_ids.append(alloc, resolveAstType(el.value, table)) catch unreachable;
}
if (el.name) |n| {
any_named = true;
name_ids_list.append(alloc, table.internString(n)) catch unreachable;
} else {
name_ids_list.append(alloc, table.internString("")) catch unreachable;
}
}
const names: ?[]const StringId = if (any_named) name_ids_list.items else null;
return table.intern(.{ .tuple = .{
.fields = field_ids.items,
.names = names,
} });
}
// Returns true when this AST node, on its own, denotes a type rather than a
// value. Used to guard tuple-literal-as-type reinterpretation: a tuple literal
// becomes a tuple type only when every element is a type.
pub fn isTypeShapedAstNode(node: *const Node, table: *TypeTable) bool {
return switch (node.data) {
.type_expr,
.pointer_type_expr,
.many_pointer_type_expr,
.array_type_expr,
.slice_type_expr,
.optional_type_expr,
.function_type_expr,
.closure_type_expr,
.tuple_type_expr,
.parameterized_type_expr,
=> true,
.identifier => |id| table.findByName(table.internString(id.name)) != null,
.tuple_literal => |tl| blk: {
for (tl.elements) |el| {
if (!isTypeShapedAstNode(el.value, table)) break :blk false;
}
break :blk true;
},
else => false,
};
}
fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTable) TypeId {
// Strip module prefix (e.g. "std.Vector" → "Vector")
const base_name = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name;

View File

@@ -505,7 +505,8 @@ pub const Server = struct {
.{ .label = "field_count", .detail = "($T: Type) -> s32" },
.{ .label = "field_name", .detail = "($T: Type, idx: s32) -> string" },
.{ .label = "field_value", .detail = "(s: $T, idx: s32) -> Any" },
.{ .label = "size_of", .detail = "($T: Type) -> s32" },
.{ .label = "size_of", .detail = "($T: Type) -> s64" },
.{ .label = "align_of", .detail = "($T: Type) -> s64" },
.{ .label = "cast", .detail = "(Type) expr — prefix type cast" },
.{ .label = "malloc", .detail = "(size: s64) -> *void" },
.{ .label = "free", .detail = "(ptr: *void) -> void" },
@@ -974,7 +975,8 @@ pub const Server = struct {
.{ .name = "field_count", .label = "field_count($T: Type) -> s32", .params = &.{"$T: Type"} },
.{ .name = "field_name", .label = "field_name($T: Type, idx: s32) -> string", .params = &.{ "$T: Type", "idx: s32" } },
.{ .name = "field_value", .label = "field_value(s: $T, idx: s32) -> Any", .params = &.{ "s: $T", "idx: s32" } },
.{ .name = "size_of", .label = "size_of($T: Type) -> s32", .params = &.{"$T: Type"} },
.{ .name = "size_of", .label = "size_of($T: Type) -> s64", .params = &.{"$T: Type"} },
.{ .name = "align_of", .label = "align_of($T: Type) -> s64", .params = &.{"$T: Type"} },
.{ .name = "cast", .label = "cast(Type) expr", .params = &.{"Type"} },
.{ .name = "malloc", .label = "malloc(size: s64) -> *void", .params = &.{"size: s64"} },
.{ .name = "free", .label = "free(ptr: *void) -> void", .params = &.{"ptr: *void"} },

View File

@@ -2240,6 +2240,10 @@ pub const Parser = struct {
if (self.isLambda()) {
return self.parseLambda();
}
// Function-type literal: (T1, T2) -> R (no body — isLambda would have caught a body)
if (self.isFunctionTypeExprAtLParen()) {
return try self.parseTypeExpr();
}
self.advance(); // skip '('
// Check for named tuple: (name: expr, ...)
@@ -2312,8 +2316,7 @@ pub const Parser = struct {
null;
return try self.createNode(start, .{ .return_stmt = .{ .value = value } });
},
.l_bracket => {
// Type expression in expression position: []T.[...] or [N]T.[...]
.l_bracket, .star, .question => {
return try self.parseTypeExpr();
},
.l_brace => {
@@ -2728,6 +2731,32 @@ pub const Parser = struct {
return self.current.tag;
}
/// Returns true when the current `(` opens a function-type literal `(T1, T2) -> R`
/// rather than a tuple/grouping/lambda. Only meaningful after `isLambda` has
/// returned false — at that point a trailing `->` after the matching `)` can
/// only be a function type, since any body (`=>` or `{`) would have made it
/// a lambda.
fn isFunctionTypeExprAtLParen(self: *Parser) bool {
const saved_lexer = self.lexer;
const saved_current = self.current;
const saved_prev_end = self.prev_end;
defer {
self.lexer = saved_lexer;
self.current = saved_current;
self.prev_end = saved_prev_end;
}
self.advance(); // skip '('
var depth: u32 = 1;
while (depth > 0 and self.current.tag != .eof) {
if (self.current.tag == .l_paren) depth += 1;
if (self.current.tag == .r_paren) depth -= 1;
if (depth > 0) self.advance();
}
if (self.current.tag != .r_paren) return false;
self.advance(); // skip ')'
return self.current.tag == .arrow;
}
fn isLambda(self: *Parser) bool {
const saved_lexer = self.lexer;
const saved_current = self.current;
@@ -2845,7 +2874,55 @@ pub const Parser = struct {
// ends with `;` directly after the param list — recognise it as a
// function def (not a constant) so it goes through parseFnDecl.
if (self.struct_default_compiler and tag == .semicolon) return true;
return tag == .l_brace or tag == .arrow or tag == .hash_builtin or tag == .hash_compiler or tag == .hash_foreign or tag == .fat_arrow or tag == .kw_callconv;
// `(T1, T2) -> R` without a trailing body (`{`, `=>`, or a foreign/
// builtin marker) is a function-type literal, not a function def.
if (tag == .arrow) return self.hasFnBodyAfterArrow();
return tag == .l_brace or tag == .hash_builtin or tag == .hash_compiler or tag == .hash_foreign or tag == .fat_arrow or tag == .kw_callconv;
}
fn hasFnBodyAfterArrow(self: *Parser) bool {
const saved_lexer = self.lexer;
const saved_current = self.current;
const saved_prev_end = self.prev_end;
defer {
self.lexer = saved_lexer;
self.current = saved_current;
self.prev_end = saved_prev_end;
}
self.advance(); // skip '('
var depth: u32 = 1;
while (depth > 0 and self.current.tag != .eof) {
if (self.current.tag == .l_paren) depth += 1;
if (self.current.tag == .r_paren) depth -= 1;
if (depth > 0) self.advance();
}
if (self.current.tag != .r_paren) return false;
self.advance(); // skip ')'
if (self.current.tag != .arrow) return false;
self.advance(); // skip '->'
while (self.current.tag != .eof) {
if (self.current.tag == .fat_arrow) return true;
if (self.current.tag == .l_brace) return true;
if (self.current.tag == .hash_builtin or self.current.tag == .hash_compiler or self.current.tag == .hash_foreign) return true;
if (self.current.tag == .kw_callconv) return true;
// Inside a `struct #compiler` block, a `(...) -> Ret;` ending
// with `;` after the return type is a `#compiler` method
// declaration (body implicit). Outside that context, the same
// shape is a function-type alias (no body) and falls through to
// const-decl parsing.
if (self.struct_default_compiler and self.current.tag == .semicolon) return true;
if (self.current.tag == .identifier or self.current.tag.isTypeKeyword() or
self.current.tag == .dot or self.current.tag == .dollar or
self.current.tag == .l_bracket or self.current.tag == .r_bracket or
self.current.tag == .l_paren or self.current.tag == .r_paren or
self.current.tag == .comma or self.current.tag == .int_literal or
self.current.tag == .star or self.current.tag == .question or
self.current.tag == .colon or self.current.tag == .arrow)
{
self.advance();
} else break;
}
return false;
}
fn parseOptionalCallConv(self: *Parser) anyerror!ast.CallingConvention {

View File

@@ -655,7 +655,7 @@ pub const Analyzer = struct {
}
// Built-in names that aren't declared in source
const builtins = [_][]const u8{ "io", "true", "false", "cast", "closure", "out", "size_of", "malloc", "free", "memcpy", "memset" };
const builtins = [_][]const u8{ "io", "true", "false", "cast", "closure", "out", "size_of", "align_of", "malloc", "free", "memcpy", "memset" };
for (builtins) |b| {
if (std.mem.eql(u8, name, b)) return;
}