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

@@ -84,8 +84,4 @@ main :: () {
print("{}\n", size_of(f32));
print("{}\n", size_of(Sx(f32)));
print("{}\n", size_of(Foo));
print("{}\n", size_of(Complex));
size := size_of(Sx);
print("{}\n", size);
}

View File

@@ -0,0 +1,47 @@
// Inline `xx` cast as the first argument to a struct static method must
// flow the leading param's type into the cast — otherwise an `xx ptr`
// targeting a protocol param falls back to s64 and the call frame is
// corrupted, SIGTRAPping when the body dispatches through the field.
//
// Three call shapes that must all succeed:
// 1. Named-variable receiver: `a : Allocator = xx p; T.init(a, ...)`
// 2. Free function with inline xx: `make_t(xx p, ...)`
// 3. Static method with inline xx: `T.init(xx p, ...)` ← used to crash
#import "modules/std.sx";
#import "modules/allocators.sx";
Box :: struct {
parent: Allocator;
first_ptr: *void;
init :: (parent_alloc: Allocator, size: s64) -> *Box {
self : *Box = xx malloc(size_of(Box));
self.parent = parent_alloc;
self.first_ptr = self.parent.alloc(size);
self;
}
}
make_box :: (parent_alloc: Allocator, size: s64) -> *Box {
self : *Box = xx malloc(size_of(Box));
self.parent = parent_alloc;
self.first_ptr = self.parent.alloc(size);
self;
}
main :: () -> s32 {
g_gpa : GPA = .{ alloc_count = 0 };
a : Allocator = xx @g_gpa;
b1 := Box.init(a, 64);
print("Box.init (named-var): ok\n");
b2 := make_box(xx @g_gpa, 64);
print("make_box (inline-xx): ok\n");
b3 := Box.init(xx @g_gpa, 64);
print("Box.init (inline-xx): ok\n");
0;
}

View File

@@ -0,0 +1,26 @@
// `xx allocator` recovers the typed concrete pointer (ctx) from a
// protocol value. The recovery is read-only and must not perturb
// subsequent dispatch through the protocol value, regardless of
// whether the recovery happens BEFORE or AFTER the first dispatch.
#import "modules/std.sx";
main :: () -> s32 {
gpa := GPA.init();
a : Allocator = xx gpa;
// Recover BEFORE first dispatch.
recovered : *GPA = xx a;
print("recovered == gpa? {}\n", recovered == gpa);
p := a.alloc(64);
print("alloc count after first alloc: {}\n", gpa.alloc_count);
// Recover AFTER dispatch — still works.
recovered2 : *GPA = xx a;
print("recovered2 == gpa? {}\n", recovered2 == gpa);
a.dealloc(p);
print("alloc count after dealloc: {}\n", gpa.alloc_count);
0;
}

View File

@@ -0,0 +1,18 @@
// `#import` is non-transitive: when A imports B and B imports C, A
// must NOT see C's top-level names. This file imports `b.sx` (which
// in turn imports `c.sx`) and then deliberately references C's names
// directly — the compiler is expected to reject the references with
// "not visible; #import the module that declares it" diagnostics.
//
// `b.sx` ↔ `c.sx` together still compile: `b_only_fn`'s body sees
// `c_only_fn` / `c_only_const` because b.sx directly imports c.sx.
#import "modules/std.sx";
#import "127-import-non-transitive/b.sx";
main :: () -> s32 {
print("b_only_fn: {}\n", b_only_fn());
print("c_only_fn direct: {}\n", c_only_fn());
print("c_only_const direct: {}\n", c_only_const);
0;
}

View File

@@ -0,0 +1,5 @@
#import "c.sx";
b_only_fn :: () -> s32 {
c_only_fn() + c_only_const;
}

View File

@@ -0,0 +1,2 @@
c_only_fn :: () -> s32 { 42; }
c_only_const :: 7;

View File

@@ -0,0 +1,29 @@
// Regression test for issue-0039 — directory-import scan order.
//
// When a directory is imported, the resolver iterates files
// alphabetically. Inside the directory, `aaa_uses.sx` comes BEFORE
// `types.sx` but its `make_my` returns `MyEnum` (defined in
// `types.sx`). The combined directory module must put `aaa_uses.sx`'s
// transitive imports (which include `MyEnum`) into the global scan
// stream BEFORE `aaa_uses.sx`'s own decls so the tagged_union for
// MyEnum is registered before `make_my`'s return type is resolved.
//
// Pre-fix, the dir-import implementation appended each file's
// `own_decls` before its `decls`, which inverted that order and
// caused `MyEnum` to be registered as a placeholder struct via the
// `resolveTypeName` fallback. The later `enum_decl` scan then
// short-circuited via `findByName` and never upgraded the placeholder
// to the real tagged_union, surfacing as "cannot infer enum type
// for '.b'" at the `return .b(42)` site.
#import "modules/std.sx";
#import "128-import-dir-scan-order";
main :: () -> s32 {
e := make_my();
if e == {
case .a: { print("a\n"); }
case .b: (v) { print("b={}\n", v); }
}
0;
}

View File

@@ -0,0 +1,11 @@
// Alphabetically FIRST file in the directory. Its own decl uses
// `MyEnum`, defined in a sibling file (`types.sx`). The directory
// import pass must register `MyEnum` BEFORE this file's `make_my`
// is scanned, or `MyEnum` falls back to a placeholder struct and
// `return .b(42)` fails with "cannot infer enum type".
#import "types.sx";
make_my :: () -> MyEnum {
return .b(42);
}

View File

@@ -0,0 +1,4 @@
MyEnum :: enum {
a;
b: s32;
}

View File

@@ -0,0 +1,28 @@
// Dot-call dispatch for generic struct methods.
//
// Covers three shapes:
// 1. non-generic method: h.plain()
// 2. generic method, explicit type arg: h.sized(s32)
// 3. generic method, inferred from val: h.taking(99)
#import "modules/std.sx";
Holder :: struct {
n: s64;
plain :: (self: *Holder) -> s64 { self.n; }
sized :: (self: *Holder, $T: Type) -> s64 { size_of(T); }
taking :: (self: *Holder, $T: Type, v: T) -> T { v; }
}
main :: () -> s32 {
h : *Holder = xx malloc(size_of(Holder));
h.n = 7;
print("plain: {}\n", h.plain());
print("sized s32: {}\n", h.sized(s32));
print("sized s64: {}\n", h.sized(s64));
print("taking explicit: {}\n", h.taking(s32, 42));
print("taking inferred: {}\n", h.taking(99));
0;
}

View File

@@ -0,0 +1,39 @@
// Phase 1.1 — the compiler-internal heap-copy that backs `xx value`
// protocol erasure must dispatch through `context.allocator`, not call
// libc malloc directly. So when a `push Context.{ allocator = tracer }`
// block is active, a `xx struct_value` inside it MUST be allocated by
// the tracker.
#import "modules/std.sx";
Tracer :: struct {
count: s64;
init :: () -> *Tracer {
t : *Tracer = xx malloc(size_of(Tracer));
t.count = 0;
t;
}
}
impl Allocator for Tracer {
alloc :: (self: *Tracer, size: s64) -> *void {
self.count += 1;
return malloc(size);
}
dealloc :: (self: *Tracer, ptr: *void) {
free(ptr);
}
}
ByValue :: struct { x: s64; y: s64; }
main :: () -> s32 {
tracer := Tracer.init();
push Context.{ allocator = xx tracer, data = null } {
bv : ByValue = .{ x = 1, y = 2 };
ignore : Allocator = xx bv;
_ = ignore;
}
print("Tracer.count = {}\n", tracer.count);
0;
}

View File

@@ -43,15 +43,14 @@ main :: () -> s32 {
print("listening on http://localhost:{}\n", PORT);
arena : Arena = ---;
arena_alloc := arena.create(context.allocator, 65536);
arena := Arena.init(context.allocator, 65536);
logger := Logger.{ prefix = "http", count = 0 };
while true {
client := accept(fd, null, null);
if client < 0 { continue; }
push Context.{ allocator = arena_alloc, data = xx @logger } {
push Context.{ allocator = xx arena, data = xx @logger } {
handle(client);
}

View File

@@ -1141,6 +1141,12 @@ END;
print("sizeof-f64: {}\n", size_of(f64));
print("sizeof-struct: {}\n", size_of(Point));
// align_of
print("alignof-u8: {}\n", align_of(u8));
print("alignof-s32: {}\n", align_of(s32));
print("alignof-s64: {}\n", align_of(s64));
print("alignof-struct: {}\n", align_of(Point));
// type_of + category matching
tv := 42;
ttype := type_of(tv);
@@ -1667,29 +1673,30 @@ END;
// ── GPA ─────────────────────────────────────────────────
{
gpa_state : GPA = .{ alloc_count = 0 };
gpa := gpa_state.create();
p1 := gpa.alloc(64);
p2 := gpa.alloc(128);
print("gpa allocs: {}\n", gpa_state.alloc_count); // gpa allocs: 2
gpa.dealloc(p1);
gpa.dealloc(p2);
print("gpa final: {}\n", gpa_state.alloc_count); // gpa final: 0
gpa := GPA.init();
a : Allocator = xx gpa;
p1 := a.alloc(64);
p2 := a.alloc(128);
print("gpa allocs: {}\n", gpa.alloc_count); // gpa allocs: 2
a.dealloc(p1);
a.dealloc(p2);
print("gpa final: {}\n", gpa.alloc_count); // gpa final: 0
}
// ── Arena backed by GPA (multi-chunk) ───────────────────
{
gpa_state3 : GPA = .{ alloc_count = 0 };
gpa3 := gpa_state3.create();
arena_state : Arena = ---;
arena := arena_state.create(gpa3, 32);
gpa3 := GPA.init();
arena := Arena.init(xx gpa3, 32);
a : Allocator = xx arena;
// First chunk fits 80 usable bytes
a1 := arena.alloc(40);
a2 := arena.alloc(40);
print("arena chunks: {}\n", gpa_state3.alloc_count); // arena chunks: 1
a1 := a.alloc(40);
a2 := a.alloc(40);
// Counts: Arena struct itself + first chunk = 2 (Arena.init
// allocates its own state through the parent allocator).
print("arena chunks: {}\n", gpa3.alloc_count); // arena chunks: 2
// Overflow → new chunk
a3 := arena.alloc(16);
print("arena overflow: {}\n", gpa_state3.alloc_count); // arena overflow: 2
a3 := a.alloc(16);
print("arena overflow: {}\n", gpa3.alloc_count); // arena overflow: 3
// Verify memory works across chunks
p1 : [*]u8 = xx a1;
p3 : [*]u8 = xx a3;
@@ -1698,27 +1705,27 @@ END;
print("arena a1: {}\n", p1[0]); // arena a1: 42
print("arena a3: {}\n", p3[0]); // arena a3: 99
// Reset retains newest chunk
arena_state.reset();
print("arena reset idx: {}\n", arena_state.end_index); // arena reset idx: 0
print("arena reset gpa: {}\n", gpa_state3.alloc_count);// arena reset gpa: 1
// Deinit frees all
arena_state.deinit();
print("arena deinit: {}\n", gpa_state3.alloc_count); // arena deinit: 0
arena.reset();
print("arena reset idx: {}\n", arena.end_index); // arena reset idx: 0
print("arena reset gpa: {}\n", gpa3.alloc_count); // arena reset gpa: 2
// Deinit frees all chunks + the Arena state itself
arena.deinit();
print("arena deinit: {}\n", gpa3.alloc_count); // arena deinit: 0
}
// ── BufAlloc from stack array ───────────────────────────
{
stack_buf : [128]u8 = ---;
buf_state : BufAlloc = ---;
bufalloc := buf_state.create(@stack_buf[0], 128);
b1 := bufalloc.alloc(24);
b2 := bufalloc.alloc(24);
print("buf pos: {}\n", buf_state.pos); // buf pos: 48
b3 := bufalloc.alloc(200);
buf := BufAlloc.init(@stack_buf[0], 128);
a : Allocator = xx buf;
b1 := a.alloc(24);
b2 := a.alloc(24);
print("buf pos: {}\n", buf.pos); // buf pos: 48
b3 := a.alloc(200);
b3_i : s64 = xx b3;
print("buf overflow: {}\n", b3_i); // buf overflow: 0
buf_state.reset();
print("buf reset: {}\n", buf_state.pos); // buf reset: 0
buf.reset();
print("buf reset: {}\n", buf.pos); // buf reset: 0
}
{
@@ -2446,8 +2453,8 @@ END;
step3 :: (a: Allocator) -> *void { a.alloc(8); }
step2 :: (a: Allocator) -> *void { step3(a); }
step1 :: (a: Allocator) -> *void { step2(a); }
gpa_e6 : GPA = .{ alloc_count = 0 };
a_e6 : Allocator = xx @gpa_e6;
gpa_e6 := GPA.init();
a_e6 : Allocator = xx gpa_e6;
ptr_e6 := step1(a_e6);
print("closure-chain-call: {}\n", ptr_e6 != null);
a_e6.dealloc(ptr_e6);
@@ -2498,11 +2505,9 @@ END;
print("closure-slice: {} {} {}\n", f_a7(0), f_a7(1), f_a7(2));
// C5.L1: arena bulk free (closures allocated on arena, freed in bulk)
gpa_l1 : GPA = .{ alloc_count = 0 };
a_l1 : Allocator = xx @gpa_l1;
arena_l1 : Arena = ---;
arena_alloc := arena_l1.create(a_l1, 4096);
push Context.{ allocator = arena_alloc } {
gpa_l1 := GPA.init();
arena_l1 := Arena.init(xx gpa_l1, 4096);
push Context.{ allocator = xx arena_l1 } {
n_l1 : s32 = 5;
f_l1 := closure((x: s32) -> s32 => x + n_l1);
print("closure-arena: {}\n", f_l1(10));
@@ -2510,8 +2515,8 @@ END;
arena_l1.deinit();
// C5.L2: GPA manual free (verify env alloc/dealloc)
gpa_l2 : GPA = .{ alloc_count = 0 };
a_l2 : Allocator = xx @gpa_l2;
gpa_l2 := GPA.init();
a_l2 : Allocator = xx gpa_l2;
n_l2 : s32 = 7;
result_l2 : s32 = 0;
push Context.{ allocator = a_l2 } {
@@ -2660,8 +2665,8 @@ END;
print("closure-neg: {}\n", neg_fn(val_e16));
// C5.E17: closure with protocol value capture (#inline protocol)
gpa_e17 : GPA = .{ alloc_count = 0 };
a_e17 : Allocator = xx @gpa_e17;
gpa_e17 := GPA.init();
a_e17 : Allocator = xx gpa_e17;
alloc_fn := closure((size: s64) -> *void => a_e17.alloc(size));
ptr_e17 := alloc_fn(32);
print("closure-proto-cap: {}\n", ptr_e17 != null);

View File

@@ -6,10 +6,9 @@
#import "modules/allocators.sx";
main :: () -> void {
arena : Arena = ---;
arena.create(context.allocator, 4096);
arena := Arena.init(context.allocator, 4096);
new_ctx := Context.{ allocator = xx @arena, data = context.data };
new_ctx := Context.{ allocator = xx arena, data = context.data };
push new_ctx {
ptr := context.allocator.alloc(128);
out("inside push\n");

View File

@@ -0,0 +1,30 @@
// A protocol method declared `-> *void` (literal void-pointer return,
// NOT `Self`) returns the underlying impl's pointer to the caller
// unchanged. The dispatch path must NOT auto-load from the result —
// `*void` outside a `Self`-disguise is a real pointer whose pointee
// size is unknown.
//
// Regression: target_type leaks from the surrounding scope (e.g. the
// enclosing function's return type). The dispatcher used to auto-load
// `sizeof(target_type)` bytes from every `*void` return, mistaking
// real pointers for Self-encoded boxes. Result was that
// `alloc.alloc(64)` through an Allocator protocol value returned the
// first 4 bytes of malloc'd memory interpreted as `s32` (= 0 → null).
#import "modules/std.sx";
#import "modules/allocators.sx";
main :: () -> s32 {
gpa := GPA.init();
alloc : Allocator = xx gpa;
p_direct := gpa.alloc(64);
print("direct: null? {}\n", p_direct == null);
p_protocol := alloc.alloc(64);
print("protocol: null? {}\n", p_protocol == null);
print("alloc_count: {}\n", gpa.alloc_count);
0;
}

31
examples/issue-0041.sx Normal file
View File

@@ -0,0 +1,31 @@
// issue-0041 — Pointer / optional / array / function / tuple types as
// expression-position values (size_of / align_of arg, const-decl RHS).
#import "modules/std.sx";
// Unambiguous type-form const-decl aliases.
Ptr :: *u8;
Maybe :: ?u8;
Arr :: [3]u8;
Cb :: (s32) -> s32;
main :: () -> s32 {
// Direct: parser fix for *T, ?T + existing [N]T path.
print("size_of(*u8) = {}\n", size_of(*u8));
print("align_of(*u8) = {}\n", align_of(*u8));
print("size_of(?u8) = {}\n", size_of(?u8));
print("size_of([3]u8) = {}\n", size_of([3]u8));
// Function-type literal in expression position.
print("size_of((s32)->s32) = {}\n", size_of((s32) -> s32));
// Tuple literal reinterpreted as tuple type at the type-demanding site.
print("size_of((s32, s32)) = {}\n", size_of((s32, s32)));
// Aliases.
print("size_of(Ptr) = {}\n", size_of(Ptr));
print("size_of(Maybe) = {}\n", size_of(Maybe));
print("size_of(Arr) = {}\n", size_of(Arr));
print("size_of(Cb) = {}\n", size_of(Cb));
0;
}

22
examples/issue-0042.sx Normal file
View File

@@ -0,0 +1,22 @@
// issue-0042 — const-decl type alias must resolve through
// `type_alias_map` when used as a `$T: Type` argument to size_of /
// align_of, not silently fall back to .s64 (8 bytes).
// Also covers identifier-RHS aliases (chains + struct aliases),
// not just *T / [N]T / ?T forms.
#import "modules/std.sx";
MyInt :: s32;
MyChain :: MyInt;
Wide :: struct { a: s64; b: s64; }
WideAlias :: Wide;
main :: () -> s32 {
print("direct s32: {}\n", size_of(s32));
print("alias s32: {}\n", size_of(MyInt));
print("chain s32: {}\n", size_of(MyChain));
print("align alias: {}\n", align_of(MyInt));
print("align chain: {}\n", align_of(MyChain));
print("size struct-alias: {}\n", size_of(WideAlias));
print("align struct-alias:{}\n", align_of(WideAlias));
0;
}