Commit Graph

211 Commits

Author SHA1 Message Date
agra
79896188eb ffi M5.A.next.2b: per-call-shape monomorphisation for pack-fns
Pack-fns (`isPackFn(fd) == true` — last param `is_variadic AND
is_comptime`, no other comptime params) now emit ONE
monomorphised function per unique call-site signature. Repeat
calls with the same arg-type tuple share the mono; distinct
shapes get distinct symbols. Pre-2b each call inlined a fresh
body copy into the caller's basic block; IR size grew linearly
in call sites.

Plumbing in `src/ir/lower.zig`:

- `isPackFn(fd)` — true when the only comptime param is a
  trailing pack. Mixed `($fmt, ..$args)` shapes stay on the
  inline `lowerComptimeCall` path (different substitution
  mechanism for the comptime non-pack param; deferred).
- `lowerPackFnCall(fd, call_node)`:
  - Builds a mangled name `<fn_name>__pack__<arg_types>` from
    call-site `inferExprType` results. Distinct shapes get
    distinct symbols.
  - Cache-checks `lowered_functions`; calls
    `monomorphizePackFn` on miss.
  - Lowers call args, then re-fetches the func pointer (the
    fetch BEFORE arg lowering would invalidate after any
    transitively-triggered module.functions.items realloc),
    prepends ctx if needed, coerces, emits direct call.
- `monomorphizePackFn(fd, mangled, arg_types)`:
  - Mirrors `monomorphizeFunction` for the standard fn build:
    save state, build param list (ctx + fixed prefix + N pack
    params with synthesised names `__pack_<name>_<i>`),
    `beginFunction`, entry block, bind params to scope.
  - Installs `pack_arg_nodes[<name>]` with synthesised AST
    identifier nodes pointing at the pack-param slots so the
    body's `args[<int_literal>]` substitutes through the
    existing 2a.B mechanism — substitution resolves to the
    mono's own param slot loads.
  - Installs `pack_param_count[<name>] = N` so the body's
    `args.len` resolves to a compile-time constant via a new
    intercept in `lowerFieldAccess` (and the parallel arm in
    `inferExprType`).
  - Lowers the body with `inline_return_target = null` so
    `return X;` emits a real `ret X` instead of the inline-slot
    routing — the mono is a real fn now.
- Routed at three call sites: each `if (hasComptimeParams(fd))
  { return self.lowerComptimeCall(...); }` now first checks
  `isPackFn(fd)` and routes to `lowerPackFnCall` when true.

Lifetime gotcha caught and fixed: `params.items` is stored by
reference in `Function.init` (no copy), so the local
`ArrayList(Function.Param)` must NOT be deinit'd in
`monomorphizePackFn` — matches the leak convention already used
by `monomorphizeFunction`.

`examples/158-pack-mono-dedup.sx` confirms the dedup
end-to-end: `count(), count(1), count(2), count(1,2,3),
count("x", true)` produces `0 1 1 3 2` at runtime AND emits
exactly 4 monos in IR (`count__pack`, `count__pack_s64`,
`count__pack_s64_s64_s64`, `count__pack_string_bool`) — the
two s64 calls share. `args.len` resolves to the comptime
constant N inside each mono.

`examples/156-pack-typed-index.sx` and
`examples/157-pack-if-return.sx` continue to pass unchanged.

Out of scope:
- Mixed `$fmt + ..$args` shapes (stays on inline path).
- Generic `$R` return types (concrete returns only).
- Bare `args` reference (passing the slice as a whole).
- `args[<runtime_int>]` (non-literal index).

197/197 example tests + `zig build test` green.
2026-05-27 15:44:05 +03:00
agra
e6d6903708 ffi M5.A.next.2a.D: inline-return uses CFG terminator, not block_terminated
Fixes the regression locked in by 2a.C (commit 6b7a66b).
issue-0045's original fix set `block_terminated = true` after
each inline `return X;` to skip dead code in the inlined body.
But the flag leaked past structured control flow — an `if cond
{ return X; }` whose merge block continued to subsequent
statements would short-circuit the trailing code at the
`lowerBlockValue` loop's `if (self.block_terminated) return
null;` check.

Switched to the classical SSA "return-done block" shape:

- `InlineReturnInfo` carries a third field `done_bb: BlockId`
  — a fresh basic block allocated by `lowerComptimeCall` per
  comptime-call instance.
- `lowerReturn`'s inline path stores into the slot, drains
  defers, and emits `br done_bb`. The basic block's terminator
  is what carries the "no fall-through" signal; the
  `block_terminated` flag is no longer touched.
- `lowerComptimeCall` allocates the slot + done_bb, lowers the
  body, then switches to done_bb and loads the slot. Tail-
  expression bodies that fall through (rare when has_return is
  true) get a synthetic store + br so the CFG is well-formed.

For `if cond { return 42; }; return -1;`:
- cond=true: then's `return 42` stores 42, br done_bb. Merge
  block has only the false predecessor, doesn't run the
  trailing return. Load done_bb → 42.
- cond=false: condBr skips to merge. Merge runs `return -1;`
  → store -1, br done_bb. Load → -1.

`examples/157-pack-if-return.sx` flips from `8354116000` (the
uninitialised slot load on the false path) to `-1`. A
three-way `classify(..$args)` smoke confirms multi-path
inline-return works for any of the three branches.

Dead-code-after-return inside the inlined body still trips the
LLVM verifier (same shape as a regular `return X; print("dead");`
which also crashes today). Acceptable consistency — user code
shouldn't write unreachable code in either context.

196/196 example tests + `zig build test` green.
2026-05-27 14:55:25 +03:00
agra
cd367847a9 ffi M5.A.next.2a.B: pack typed indexing — args[$i] substitutes call arg
Pack-fn bodies that index the pack via `args[<int_literal>]`
now resolve to the i-th call-site argument's lowered value
directly, propagating the call arg's concrete type instead
of the boxed `Any` that the `[]Any` slice path returns.

New plumbing in `src/ir/lower.zig`:

- `pack_arg_nodes: ?std.StringHashMap([]const *const Node)` on
  Lowering. Maps a pack param name (e.g. "args") to the slice
  of call-site arg AST nodes.
- `lowerComptimeCall` populates the map when the variadic
  param is heterogeneous (`is_variadic AND is_comptime`, i.e.
  the `..$args` form). Plain `args: ..Any` keeps the existing
  `[]Any` slice path so stdlib's `format`/`print` continue
  unchanged. The map is saved/restored across nested calls
  mirroring `comptime_param_nodes`.
- `packArgNodeAt(ie)` returns the call-arg node when an
  index_expr matches `<pack_name>[<comptime_int_literal>]`
  with the index in range; null otherwise (fall through to
  standard slice indexing for runtime indices or non-pack
  bases).
- `lowerIndexExpr` checks `packArgNodeAt` first; on a hit it
  lowers the call arg node directly. `inferExprType`'s
  `index_expr` arm does the parallel check so AST-level type
  inference (e.g., for field-access type checking) sees the
  concrete call-arg type.

`examples/156-pack-typed-index.sx` flips from
"field 'x' not found on type 'Any'" to `7` — `args[0].x` now
resolves through the concrete `Point` type instead of Any.

Out of scope (deferred): non-literal comptime indices
(`args[$i]` where `$i` is an arbitrary comptime expression);
`$args[$i]` in type positions (step 3); per-mono mangling
(monomorphisation stays inline-only).

195/195 example tests + `zig build test` green.
2026-05-27 13:55:19 +03:00
agra
9e78790ebf ffi issue-0045 fix: inline-return slot for comptime-call bodies
`lowerComptimeCall` now scans the body for `return` statements
via `fnBodyHasReturn`. When found, it allocates a stack slot
typed to the fn's return type and installs it as
`self.inline_return_target` before lowering the body.

`lowerReturn` checks `inline_return_target` first:
- If set, it stores the coerced return value into the slot,
  drains pending defers, sets `block_terminated = true`, and
  returns without emitting a `ret` into the caller's basic
  block.
- Otherwise it emits the standard `ret` as before.

After the body lowers, the inliner either returns the
tail-expression value (existing fast path — bodies with no
`return` skip the slot entirely) or loads the slot when
`block_terminated` is set.

Why the bug was invisible until now: `format`/`print` and
every other stdlib comptime fn use arrow form (`=> expr`) or
`#insert`-only bodies — no `return` statement, no path through
`lowerReturn`. Step 1.b of the pack feature made `..$args`
parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first
comptime-fn body to take the `return`-with-trailing-statements
path, surfacing the LLVM verifier crash.

`examples/issue-0045.sx` flips from the lock-in failure to
`42`. 194/194 example tests + `zig build test` green.
2026-05-27 13:21:23 +03:00
agra
08feb6040b ffi M5.A.next.1d.B: pack impl matching — bind $args + $R per call
Pack-shaped impls (`impl P(...) for Closure(..$args) -> $R`) now
match concrete closure sources at xx resolution time. Concrete
impls keep their priority — pack matching only fires on a
concrete-key miss in `param_impl_map`.

New plumbing in src/ir/lower.zig:

- `PackParamImplEntry` carries the pack-shaped source TypeId plus
  the pack-var and ret-var names extracted from the impl AST's
  `target_type_expr`. `registerParamImpl` detects pack-shaped
  sources via `pack_start != null` on the resolved closure type
  and additionally registers in a new `param_impl_pack_map`
  keyed by `"Proto\x00<arg_mangled>"` (no source suffix).

- `tryUserConversion` re-shapes the concrete lookup so the pack
  path runs on miss. `tryPackImplMatch` walks the pack entries,
  verifies the source's fixed prefix matches the impl's prefix,
  binds the pack-var to the source's tail param TypeIds, binds
  the ret-var (when the impl's return is generic) to the source
  return, and monomorphises the convert method. Mangled name
  stays keyed on the concrete source so distinct call shapes
  monomorphise separately.

- `pack_bindings: ?StringHashMap([]const TypeId)` is saved/
  restored around monomorphisation, mirroring `type_bindings`.

- `resolveClosureTypeWithBindings` handles the closure_type_expr
  node during type resolution: when the closure carries a
  `pack_name` AND `pack_bindings` has a binding for it, the
  bound TypeIds are appended after the fixed prefix and the
  result is a concrete (non-pack) closure type — so the impl
  body's `self: Closure(..$args) -> $R` substitutes to the
  concrete source closure during monomorphisation. Without an
  active binding, the pack shape is preserved.

`examples/155-pack-impl-match.sx` flips from the
"no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to
"pack impl match ok": one user-declared
`impl Into(Block) for Closure(..$args) -> $R` covers a
`Closure(s32, bool) -> bool` source that stdlib has no
hand-rolled impl for. Constructed Block isn't invoked
(invoke=null) — the test exercises only the matching +
monomorphisation, not the trampoline (step 5 of the plan).

Existing concrete-impl paths unchanged: 95-objc-block-noop,
96-objc-block-multi-arg, and stdlib's hand-rolled
`Into(Block) for Closure(bool) -> void` continue to pass through
the concrete map first. Same-file duplicate pack impls
diagnose at registration; cross-module visibility and
multi-pack-impl specificity stay TODOs (matching the deferred
Phase 5 work on the concrete path).

193/193 example tests + `zig build test` green.
2026-05-27 12:57:45 +03:00
agra
65824494a7 ffi M5.A.next.1c.B: pack type rep — Closure(..$args) parses + interns
`parseTypeExpr`'s `Closure(...)` arm now accepts a trailing
`..$name` (sigil optional) as a variadic-pack marker. Pack must
be terminal — `)` is the only token accepted after the name.
`ClosureTypeExpr` AST gains `pack_name: ?[]const u8` carrying the
identifier so later slices can name the binding.

`FunctionInfo` / `ClosureInfo` in src/ir/types.zig grow a
`pack_start: ?u32 = null` field. `Closure(..$args) -> R` interns
as `params = []`, `pack_start = Some(0)` — distinct from any
concrete `Closure(...) -> R` shape thanks to updated hash/eql
arms. New constructor pair `closureTypePack` /
`functionTypePack` keeps the existing single-shape constructors
unchanged.

`type_bridge.resolveClosureType` calls `closureTypePack` when
`pack_name != null`. The pack starts after the fixed prefix,
so `Closure(Prefix, ..$args)` resolves with `params = [Prefix]`,
`pack_start = Some(1)`.

No semantic effect yet — the signature exists in the type table
but no matching code reads `pack_start`. Step 1d wires impl
matching: `Closure(..$args) -> $R` binds against any concrete
closure source type in `tryUserConversion` / `registerParamImpl`.

`examples/154-pack-type-rep.sx` flips from rejecting-with-error
to positive parse smoke (prints "pack type rep ok").

192/192 example tests + `zig build test` green.
2026-05-27 12:12:16 +03:00
agra
a51fe26cbf ffi M5.A.next.1b: parser accepts ..$args as a variadic-pack param
Extends parseParams in src/parser.zig:1558 to recognize a leading
`..` before the optional `$` sigil and the parameter name. The
old `args: ..T` form (variadic marker after the colon) still
works — both paths set the same `is_variadic` flag.

A pack declaration `..$args` parses as:
- `is_variadic = true` (from the leading `..`)
- `is_comptime = true` (from the `$` sigil)
- `type_expr = inferred_type` (no `:` annotation)

The no-colon branch now propagates `is_variadic` and `is_comptime`
onto the Param struct so later slices (type rep, impl matching,
monomorphisation) can read both flags from the parsed AST without
re-deriving from token sequence.

`examples/150-pack-parse.sx` flips from rejecting-with-error to
positive parse smoke. No semantic effect yet — `foo` is declared
but never instantiated.

191/191 example tests + `zig build test` green.
2026-05-27 09:49:41 +03:00
agra
07f25689ff ffi M5.A revert: drop compiler synthesis, require explicit Into(Block) impls
Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.

New design (strict mode):

1. Stdlib's modules/std/objc_block.sx hand-rolls
   `__block_invoke_void` + `Into(Block) for Closure() -> void` and
   the same pair for `Closure(bool) -> void` (restored from M5.A.2).
   These are readable reference implementations of the bridge ABI.

2. The compiler intercept fires NO synthesis — instead, when
   `tryUserConversion` can't find a reachable `Into(Block)` impl for
   the closure's signature, it emits a focused diagnostic:
     "no `Into(Block) for <Closure-sig>` impl — add a per-signature
      `__block_invoke_<sig>` trampoline + Into impl alongside the
      existing ones in modules/std/objc_block.sx, or declare it in
      your own code"

3. Per-signature declarations live in stdlib (for common signatures)
   or in user code (for app-specific ones). 96-objc-block-multi-arg
   now demonstrates the user-side pattern in-file — it declares its
   own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
   *void) -> void` impl alongside its main().

Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
  source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
  pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
  not silent fallthrough.

Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.

190/190 example tests pass; chess on iOS-sim green.
2026-05-27 00:34:26 +03:00
agra
22c087ff35 ffi M5.A: compiler-synthesised __block_invoke_<sig> trampolines
`xx closure : Block` casts now bypass the user-space Into(Block)
protocol path entirely. The compiler intercepts in
`tryUserConversion` BEFORE the Into lookup, detects when src is
`Closure(...)` and dst is `Block`, and emits:

1. A C-ABI trampoline `__block_invoke_<sig>` (deduped per closure
   signature via `block_invoke_trampolines` map). Body matches the
   existing hand-rolled `__block_invoke_void` exactly: load
   block_self struct, extract sx_env (field 5) + sx_fn (field 6),
   call sx_fn(__sx_default_context, sx_env, ...user_args), return.

2. Inline Block-struct construction at the cast site:
   `Block { isa = &_NSConcreteStackBlock, flags=0, reserved=0,
            invoke = &__block_invoke_<sig>,
            descriptor = &__sx_block_descriptor,
            sx_env = closure.env, sx_fn = closure.fn_ptr }`

Signature mangling: compact codes — `v` void, `b` bool, `i` s32,
`q` s64, `f` f32, `d` f64, `c/C/s/S/I/Q` for other ints, `p` for
pointers/aggregates that lower to a machine word. Return first,
then params underscore-joined. `Closure() -> void` mangles to `v`;
`Closure(bool) -> void` mangles to `v_b`.

Loud failures at the cast site:
- `Block` struct missing → "requires #import \"modules/std/objc_block.sx\";"
- `_NSConcreteStackBlock` extern missing → same diagnostic.
- `__sx_block_descriptor` global missing → same.
- `__sx_default_context` missing inside the trampoline emitter →
  compiler-bug diagnostic (the scan pass should always register it).

The existing hand-rolled stdlib impls (`__block_invoke_void`,
`__block_invoke_bool`, the two `Into(Block) for Closure(...)`
impls) are now redundant — the compiler-synthesised trampoline
takes over via the intercept. Next commit (M5.A.2) removes them.

95-objc-block-noop continues to pass; IR shows `__block_invoke_v`
(the synthesised name) replacing the hand-rolled
`__block_invoke_void` at the cast site. 189/189 example tests
pass; chess on iOS-sim green.
2026-05-27 00:23:19 +03:00
agra
fcbd7a4235 ffi M4.B dealloc: release strong/copy property ivars + destroyWeak weak
emitObjcDefinedClassDeallocImp now walks the class's #property fields
BEFORE freeing the state struct. For each:

- assign  → no-op (primitives, no ARC traffic).
- strong  → val = load field; objc_release(val).
- copy    → same as strong (the stored value is a +1 retained copy
            produced by the setter's [val copy]; we release it here).
- weak    → objc_destroyWeak(&field) — unregisters the slot from
            libobjc's side-table so the runtime stops tracking it.

Order matters: property releases happen BEFORE freeing the state
struct (which would invalidate the pointers we need to read), which
happens BEFORE [super dealloc] (which eventually frees the Obj-C
instance's own memory). The full sequence is now:

  %state    = object_getIvar(self, __sx_state_ivar)
  // M4.B (this commit):
  for each strong/copy property P:
      val = load struct_gep(state, P.idx); objc_release(val)
  for each weak property P:
      objc_destroyWeak(struct_gep(state, P.idx))
  // M4.0c (already shipped):
  allocator = load struct_gep(state, 0)
  allocator.dealloc(state)
  object_setIvar(self, ivar, null)
  // M1.2 A.6:
  [super dealloc]   // → objc_msgSendSuper2

ffi-objc-arc-02-strong-property now passes: child held by parent's
strong property gets released when parent deallocates, refcount → 0,
child deallocates, both states freed via tracker. Balanced 2/2.

189/189 example tests pass; chess on iOS-sim green. M4 complete.
2026-05-26 23:10:00 +03:00
agra
c88a293cf4 ffi M4.B getter: weak property reads through objc_loadWeakRetained
emitObjcDefinedPropertyGetter dispatches on objcPropertyKind. The
strong/copy/assign paths keep their bare load. The weak path:

  retained     = objc_loadWeakRetained(field_addr)
  autoreleased = objc_autorelease(retained)
  return autoreleased

`objc_loadWeakRetained` does the race-safe upgrade via libobjc's
side-table: if the target has deinitialized (or is mid-dealloc on
another thread), returns null; otherwise returns the target with
refcount bumped (+1 retained, transferred to caller).

`objc_autorelease` drops the +1 into the current pool so the
caller doesn't need to manually balance — matches Apple's auto-nil
weak-getter contract.

The bare-load weak path (still in place pre-M4.B-getter) worked
for the single-threaded test scenario because the runtime nils the
slot before the load happens. The load-retained version covers the
multi-threaded "between load and use, target deinit's" race that
silent bare-load can't.

189/189 example tests pass; chess on iOS-sim green.
2026-05-26 23:04:00 +03:00
agra
f4faef97dd ffi M4.B setter: emit ARC ops in sx-defined property setters
emitObjcDefinedPropertySetter now dispatches on objcPropertyKind to
emit the right runtime ops per Apple's ARC contract:

- assign  → bare store (primitives, explicitly opted-out object slots).
- strong  → load old; objc_retain(new); store new; objc_release(old).
            Apple's runtime treats release(NULL) as a safe no-op, so
            no explicit null-check on the old value.
- weak    → objc_storeWeak(field_addr, val) — handles first-store
            (init) and re-store (destroy + init) atomically. Registers
            the slot with libobjc's side-table; the runtime auto-nils
            it when the target deallocates.
- copy    → [val copy] (sends `copy` selector — returns retained per
            the NSCopying contract); load old; store the copied
            instance; release old.

Side-effect on the weak path: even with the bare-load getter still in
place (loaded directly from the slot), weak reads work because Apple's
runtime side-table-nils the slot at target dealloc. The getter
improvement via objc_loadWeakRetained is the next commit and is
needed for race-safe reads (between load and use, the target could
deinit on another thread); for the single-threaded test scenarios
the bare load is sufficient.

ffi-objc-arc-02-strong-property advances from "child dealloc'd at
midpoint" to "unbalanced; alloc=2 dealloc=1" — strong setter now
retains, but the M4.B-dealloc cleanup hasn't landed so the child
held by the property isn't released when the parent deallocates.
Final commit (M4.B dealloc) closes the loop.

ffi-objc-arc-03-weak-property turns fully green: storeWeak +
auto-nil side-table do the work.

189/189 example tests pass; chess on iOS-sim green.
2026-05-26 23:02:08 +03:00
agra
5c1d00a877 ffi M4.B helpers: objcPropertyKind + ARC runtime decls + xfail tests
Three pieces, no behavior change yet:

1. `ObjcPropertyKind` enum (strong/weak/copy/assign) + `objcPropertyKind`
   helper in lower.zig. Reads `field.property_modifiers`, applies the
   default rule (`*<ObjC-class>` → strong; primitives → assign), and
   emits loud diagnostics for the silent-error budget:
   - unknown modifier name (typo) → "expected one of: strong, weak, copy, ..."
   - conflicting modifiers (e.g. `strong,weak`) → "mutually exclusive"
   - `weak` on non-object slot → "requires a pointer-to-Obj-C-class type"
   - `copy` on non-object slot → same
   - `strong` (default or explicit) on `*void` → "ambiguous: specify
     #property(strong|weak|copy|assign) explicitly"
   Called from `emitObjcDefinedClassPropertyImps` for validation; the
   returned kind isn't wired into setter/getter/dealloc yet — that's
   the next three commits.

2. `ensureArcRuntimeDecls` lazily declares libobjc's ARC helpers:
   objc_retain, objc_release, objc_storeWeak, objc_loadWeakRetained,
   objc_initWeak, objc_destroyWeak. Uses the existing
   `ensureCRuntimeDecl` pattern; idempotent.

3. Fix existing NSObject method names in std/objc.sx — `isEqual_`,
   `isKindOfClass_`, `respondsToSelector_` had trailing underscores
   that the selector mangling turned into double-colon selectors
   (`isEqual::`). Removed the trailing underscore so the selectors
   come out as `isEqual:`, `isKindOfClass:`, `respondsToSelector:`
   as Apple's runtime expects.

4. Two xfail regression tests:
   - ffi-objc-arc-02-strong-property: assigns child to parent's strong
     property, releases the original child reference. Midpoint check:
     child's dealloc should NOT have fired (strong setter retained).
     Pre-M4.B-setter: child dealloc fires immediately → "FAIL: child
     dealloc'd at midpoint" snapshot. Exit code 1.
   - ffi-objc-arc-03-weak-property: assigns target to holder's weak
     property, releases target. Reads holder.target → should be null
     (auto-niled). Pre-M4.B-getter/setter: reads stale pointer →
     "FAIL: weak property didn't auto-nil" snapshot.

These will turn green as M4.B setter (commit 2), getter (commit 3),
and dealloc-cleanup (commit 4) land. Each subsequent commit updates
the snapshot to reflect the now-passing output.

189/189 example tests pass; chess on iOS-sim green.
2026-05-26 22:58:30 +03:00
agra
92ac51445d ffi M4.0c: -dealloc frees state through captured __sx_allocator
The synthesized -dealloc IMP now loads `state->__sx_allocator` (the
slot captured at +alloc time by M4.0a + M4.0b) and dispatches
`allocator.dealloc(state)` through the inline-protocol fn-ptr at
slot 2. Old behaviour was `free(state)` — went straight to libc,
ignoring whatever allocator the instance was constructed with.

After this commit, the per-instance allocator design from M1.2 A.5
is finally end-to-end correct:

  push Context.{ allocator = arena } {
      f := SxFoo.alloc();     ← arena.alloc(STATE_SIZE) + capture
      // ... use f ...
  }
  // refcount → 0 ⇒ -dealloc:
  //   load state->__sx_allocator   = arena
  //   arena.dealloc(state)         ← same allocator round-trips

TrackingAllocator now sees the alloc/dealloc pair; the deferred M1.2
A.5 work is done. Closes the loop on M4.0.

The dealloc IMP passes `__sx_default_context` as the implicit __sx_ctx
when invoking the dealloc fn-ptr — the IMP itself has no caller-side
ctx (it's called by Apple's runtime at refcount-zero), and the
default GPA is the right baseline for any nested allocations the
dealloc body might perform.

Each compiler-internal lookup that "can't fail" (Context type,
__sx_default_context global) emits a loud diagnostic instead of
silent fall-through, per the silent-error budget.

184/184 example tests pass; chess on iOS-sim green.
2026-05-26 22:30:48 +03:00
agra
2bbd63d929 ffi M4.0b: thread context.allocator through sx-defined +alloc
Two converging paths now allocate the state struct via the protocol's
allocator instead of raw malloc:

(1) sx-side `Cls.alloc()`: compiler intercepts in `lowerObjcStaticCall`
    when the receiver is a sx-defined `#objc_class` and the method is
    the niladic `alloc`. Emits the inline alloc-and-init sequence
    using the caller's `current_ctx_ref` as the context — so
    `push Context.{ allocator = my_arena } { let f := SxFoo.alloc(); }`
    honors `my_arena` end-to-end. The msgSend dispatch is bypassed
    entirely for this case.

(2) Obj-C-runtime `[Cls alloc]` (Info.plist principal class, NSCoder,
    UIKit reflection): the synthesized `+alloc` IMP shim reads
    `__sx_default_context.allocator` and calls into the same shared
    helper. The IMP has `has_implicit_ctx = false` and runs with no
    caller-side context — the default GPA is the right policy choice
    for "everything Apple's runtime instantiates".

Shared helper `emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr)`
does the work: `class_createInstance` → `ctx.allocator.alloc(STATE_SIZE)`
via the inline-protocol fn-ptr → memset 0 → store allocator at
state[0] (the M4.0a slot, captured for -dealloc's later use) →
`object_setIvar(instance, __sx_state_ivar, state)`. Loud failures
on missing globals via the diagnostics system.

The sx-side interception must explicitly bitcast the
`class_createInstance` result from `*void` to the method's declared
return type (`*<Cls>` or `?*<Cls>`). lowerVarDecl reads the Ref's IR
type when no type annotation is present, and coerceToType is a
no-op for ptr→ptr — without the bitcast, `let f := SxFoo.alloc();`
binds `f` at `*void` and downstream `f.class` / `f.method()` fails
to find anything.

-dealloc still uses `free(state)` (M4.0c rewrites it). 184/184 tests
pass; chess on iOS-sim green.
2026-05-26 22:27:33 +03:00
agra
8d7164f45f ffi M4.0a: prepend __sx_allocator to sx-defined-class state struct
State struct for an sx-defined `#objc_class` now leads with an
Allocator field at index 0 — captured at +alloc time, read by
-dealloc to free the state through the same allocator. User fields
shift to index 1+; the existing by-name lookups in
emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer
naturally resolve them at the new indices.

This step is the layout change only; the +alloc IMP still mallocs
(M4.0b will rewrite it to thread context.allocator through), and
-dealloc still uses free() (M4.0c). The field is allocated but
uninitialised; nobody reads it yet.

Storage type comes from `Context.fields[0].ty` via the new
`objcStateAllocatorType` helper — same Allocator value-shape the
implicit context machinery has used all along. If Context isn't
registered (early-init paths), the helper falls back to omitting
the field rather than synthesising a half-broken layout.

IR snapshot for 142-objc-class-method-lowering updated to reflect
the new struct shape and the +24-byte state allocation. Chess on
iOS-sim green; 184/184 example tests pass.
2026-05-26 22:07:56 +03:00
agra
a923b6f6f0 ffi fix: route foreign-class UFCS arg target_types through extends chain
For UFCS dispatch on foreign-class receivers (`#foreign #objc_class`
aliases), `resolveCallParamTypes` was returning an empty slice — both
`resolveFuncByName(qualified)` and `fn_ast_map.get(qualified)` miss
for `#foreign` methods (they live in `foreign_class_map`, not the
regular fn maps). With `param_types` empty, the per-arg `target_type`
assignment in `lowerCall` was skipped, leaving `self.target_type` as
whatever it held on entry — usually the enclosing function's return
type. Inside a `-> BOOL` method, `xx ptr` then lowered with target
type `i8`: `ptrtoint ptr to i64` → `trunc i64 to i8`, sending the low
byte of the pointer through.

Symptom: chess on iOS-sim crashed in
`-[NSNotificationCenter addObserver:selector:name:object:]` with
`observer = 0xC0` (low byte of the SxAppDelegate receiver) when the
AppDelegate method's first param was renamed to anything other than
`self`. The original session diagnosed it as a `self`-vs-`this`
hardcoding in `lower.zig`, but those hardcoded `"self"` strings are
all on compiler-synthesized parameters (init scopes, JNI stubs,
property IMPs, dealloc IMPs) — not the user-facing #objc_class body
params. The bug was in arg-type resolution.

Fix walks `foreign_class_map` + `findForeignMethodInChain` to recover
the declared param types (skipping the implicit `*Self` for instance
methods). Regression test `examples/issue-0044.sx` exercises the
BOOL-return + foreign-class arg shape; pre-fix the receiver round-trip
prints WRONG, post-fix it prints ok.
2026-05-26 16:42:21 +03:00
agra
f75923af00 uikit: type UIKitPlatform fields properly + handle optional in Obj-C encoding
The UIKitPlatform struct had a string of '*void = null; // UIWindow*'
fields — the type lived in a comment, every callsite had to 'xx'-cast
back to the real type. Migrated to the real foreign-class pointer
types now that M3 declared all the relevant '#objc_class' aliases:

  window:       ?*UIWindow
  root_vc:      ?*UIViewController
  gl_view:      ?*UIView         (SxGLView OR SxMetalView — both extend UIView)
  gl_layer:     ?*CALayer        (CAEAGLLayer OR CAMetalLayer)
  gl_ctx:       ?*EAGLContext
  display_link: ?*CADisplayLink

Each field is wrapped in '?' since the platform may not have set
it yet (gl_ctx is null in metal mode, display_link is null before
the first frame, etc.).

SxSceneDelegate's window getter/setter now take/return '?*UIWindow'
instead of '*void' so calling code doesn't need an xx-cast.

Required fix in objcTypeEncodingFromSignature: '?T' (optional) was
bailing with 'type kind not yet supported'. Apple's runtime treats
nullability as 'pointer may be null' — the wire encoding is the
same as T. Recursive unwrap handles ?*UIView → '@', ?*CADisplayLink
→ '@', etc.

Chess on iOS-sim: board renders, full pipeline intact. 183 tests
+ zig build test green.
2026-05-26 07:51:12 +03:00
agra
066840d9e0 ffi M3.2: SxSceneDelegate migrated + #implements protocol conformance
Migrates SxSceneDelegate from the hand-rolled
objc_allocateClassPair + class_addMethod + class_addProtocol
sequence to the declarative form:

  SxSceneDelegate :: #objc_class("SxSceneDelegate") {
      #extends UIResponder;
      #implements UISceneDelegate;
      #implements UIWindowSceneDelegate;

      scene_willConnectToSession_options :: (self, scene, session, options) { ... }
      window    :: (self) -> *void { ... }
      setWindow :: (self, w) { ... }
  }

emit_llvm now honors '#implements' in the class-pair init
constructor — for each #implements ProtocolAlias on the cache
entry's AST, emit before objc_registerClassPair:

  proto = objc_getProtocol("ProtocolName")
  class_addProtocol(cls, proto)

iOS checks 'class_conformsToProtocol' when instantiating scene
delegates; without the conformance the runtime silently rejects
the class and a default scene with no delegate gets created
instead. The protocol-getter returns null on dead-strip /
runtime mismatch (rare but possible) — the runtime treats
class_addProtocol(cls, null) as a no-op, so no explicit null
check needed.

Method bodies forward to the existing legacy free IMP functions
(uikit_scene_will_connect, uikit_window_getter,
uikit_window_setter) so we don't have to inline the scene-
connect setup logic (~80 lines).

uikit_register_classes is now tiny — just the two remaining
view-class helpers (M3.3 SxGLView + M3.4 SxMetalView). M3.5
deletes the function entirely once those land.

Chess on iOS-sim: board renders, scene delegate fires, touch
events route correctly. 183 example tests + zig build test
green.
2026-05-26 07:37:14 +03:00
agra
66f84f67b8 ffi M3.1 + M1.2 A.3 refactor: self=Obj-C id, self.field via ivar; SxAppDelegate migrated
Two coupled changes that unblock the uikit_register_classes
migration:

1) M1.2 A.3 — body's 'self' is the Obj-C id (opaque), NOT the
   state struct. Matches Apple's ObjC semantics where 'self' IS
   the object. Cocoa idiom 'xx self → id' works at runtime calls
   (addObserver:, etc.); previously the trampoline replaced
   'self' with the state-struct pointer, breaking any runtime
   call that expected an id.

   '*Self' substitution in resolveTypeWithBindings now points at
   foreignClassStructType(fcd) — the opaque class stub — instead
   of objcDefinedStateStructType(fcd).

   'self.field' access on a sx-defined class instance field is
   rewritten by lowerFieldAccess to go through the __sx_state
   ivar:
     state = object_getIvar(self, load(__<Cls>_state_ivar))
     val   = struct_gep(state, field_idx) → load

   Both read (lowerFieldAccess) and write (lowerAssignment) take
   this path. Compound ops (+=, -=, etc.) are supported via
   storeOrCompound. The lookup is filtered: skip property fields
   (those still go through the M2.2 msgSend getter/setter
   dispatch) and foreign classes (no state).

   New helpers in lower.zig:
   - lookupObjcDefinedStateFieldOnPointer — match check.
   - lowerObjcDefinedStateForObj — emit the object_getIvar +
     ivar-global-load idiom (shared between read + write paths).
   - lowerObjcDefinedStateFieldRead — the load path.

   Also moved the @llvm.global_ctors registration out of the
   sx-defined class-pair init constructor — global_ctors fires
   DURING dyld's framework load, before UIKit registers its Obj-C
   classes. objc_getClass("UIResponder") returned null, super
   was null, objc_registerClassPair crashed. main's entry block
   is post-framework-load but pre-user-code — exactly the right
   window. New helper injectCtorIntoMain.

2) M3.1 — SxAppDelegate migrated to declarative #objc_class.
   uikit_register_classes' hand-rolled objc_allocateClassPair +
   class_addMethod for SxAppDelegate is gone; the compiler
   synthesises the class at module init. The method bodies
   forward to the existing legacy IMP free functions
   (uikit_did_finish_launching, uikit_keyboard_will_change_frame)
   so we don't have to inline 70+ lines of keyboard-frame logic
   right now.

   Also adds UIResponder foreign-class declaration and chains
   UIView / UITextField to it via #extends UIResponder so the
   methods that previously lived on UITextField directly
   (becomeFirstResponder etc.) move to their proper home.

Chess on iOS-sim: board renders, full state intact. 183 example
tests + zig build test green.
2026-05-26 07:32:57 +03:00
agra
ea32f8a27a ffi M2.3: #extends method-resolution chaining + Obj-C parent resolution
When 'obj.method()' is called on a foreign-class pointer and the
method isn't declared on the receiver's class, the compiler walks
the '#extends' chain to find an ancestor that declared it.
Property lookup (M2.2) flows through the same chain walker.

  ParentX :: #foreign #objc_class("...") { foo :: ... }
  ChildX  :: #foreign #objc_class("...") { #extends ParentX; }

  child.foo()   // now resolves — was 'no method foo on ChildX'

Two new helpers in lower.zig:
- findForeignMethodInChain(fcd, name) walks the cache via
  fcd.members[i].extends → foreign_class_map[parent] → ...
  Depth-capped at 16 to break accidental cycles.
- findForeignPropertyInChain(fcd, name) — same shape for fields.

ALSO fixes a latent class-hierarchy bug uncovered while testing
M2.3: emit_llvm was passing the sx alias name to
objc_allocateClassPair(super, ...) rather than the actual Obj-C
runtime class name. For 'SxThing :: #objc_class(...) { #extends
NSObjectBase; }' where 'NSObjectBase' is aliased to "NSObject",
emit_llvm produced 'objc_getClass("NSObjectBase")' → NULL →
'objc_allocateClassPair(NULL, ...)' → SxThing's super-class link
was broken → '[sx_thing hash]' bypassed NSObject and crashed in
the forwarding machinery.

Fix: ObjcDefinedClassEntry gains a 'parent_objc_name' field
pre-resolved by lower.zig's 'resolveObjcParentName' through
foreign_class_map (which has the alias → foreign_path mapping).
emit_llvm just reads the resolved name from the entry.

153-objc-extends-chain.sx exercises both fixes:
  1-level: SxThing → NSObject — t.hash() walks one #extends.
  2-level: SxLeaf  → SxMiddle → NSObject — chained #extends.
Both return real NSObject.hash values from libobjc.

183 example tests pass (+1). zig build test green.
2026-05-26 01:56:25 +03:00
agra
239e7df27c ffi M2.2 (sx-defined): property getter/setter IMPs
Properties on sx-defined #objc_class declarations now synthesize
getter (always) and setter (unless 'readonly') IMPs that GEP into
the hidden state struct and load / store the corresponding field.
The state struct already holds every user-declared field
(objcDefinedStateStructType), so no new layout work — the IMPs
just dispatch a struct_gep + load/store through the __sx_state
ivar.

For each '#property' field on a sx-defined class:

  Getter '__<Cls>_<field>_imp(self, _cmd) -> T':
    state = object_getIvar(self, load(__<Cls>_state_ivar))
    return state.<field>

  Setter '__<Cls>_set<Field>_imp(self, _cmd, val) -> void':
    state = object_getIvar(self, load(__<Cls>_state_ivar))
    state.<field> = val

Both IMPs land in the cache's methods slice (mirroring the
method-IMP wiring from M1.2 A.4b.iii) so emit_llvm's
class_addMethod loop registers them on the class without
special-casing. Selector mangling:
  getter: <field>            (e.g. 'width')
  setter: set<Field>:        (e.g. 'setWidth:')
Type encoding derived from the field's resolved IR TypeId.

'readonly' (the only modifier honored in this slice) skips the
setter emission AND the corresponding method entry — so the
runtime reports the selector as absent. Other modifiers
(strong, weak, copy, assign) parse fine but stay no-ops until
M4.2 wires up ARC ops in the setter body.

152-objc-property-sx-defined.sx round-trips on macOS:
  b.width = 10; b.height = 7;
  read back through getter IMPs.
  area is readonly — class_getInstanceMethod(SxBox, sel(setArea:))
  returns NULL, confirming the setter is absent.

182 example tests pass (+1). zig build test green.
2026-05-26 01:49:31 +03:00
agra
95f13849af ffi M2.2 (first pass): #property directive on foreign-class fields
Adds:
  field: T #property[(modifier, modifier, ...)];

inside #objc_class declarations. For FOREIGN classes (this slice),
'obj.field' and 'obj.field = x' lower as objc_msgSend dispatches —
no struct GEP, no per-field storage on the sx side. The receiver
is opaque and the Obj-C runtime owns the data.

Selector mangling (Apple convention):
  getter: <fieldName>            (e.g. 'count')
  setter: set<FieldName>:        (e.g. 'setBackgroundColor:')

So:
  view.backgroundColor          → [view backgroundColor]
  view.backgroundColor = red    → [view setBackgroundColor:red]

Plumbing:
- New token hash_property + lexer entry + LSP keyword classification.
- ForeignFieldDecl gains 'is_property' + 'property_modifiers' slice;
  the parser captures both. Modifiers are recorded verbatim (strong,
  weak, copy, readonly, getter("name"), ...) — semantic interpretation
  lands with M4.2 ARC wiring.
- lowerFieldAccess: lookupObjcPropertyOnPointer() detects the case
  before the auto-deref / struct-GEP path and dispatches via
  lowerObjcPropertyGetter (objc_msg_send).
- lowerAssignment: same check on the field_access LHS routes to
  lowerObjcPropertySetter (objc_msg_send with set<Field>:).
- inferExprType: 'obj.field' returns the property's declared type
  so chained access / coerced assignment work.

151-objc-property-foreign.sx round-trips:
  inst.tag        → [inst tag]       → reads g_probe_tag → 0
  inst.tag = 42   → [inst setTag:42] → writes g_probe_tag
  inst.tag = -7   → ditto
  Final: 0 -> 42 -> -7  (real Obj-C runtime dispatch).

DEFERRED for M2.2 (later passes):
- Sx-defined property IMPs (synthesized getter/setter trampolines
  reading/writing the state struct).
- Modifier-driven setter behavior: readonly (compile error on
  write), copy (deep-copy), weak (objc_storeWeak), strong/assign
  (Month 4.2 ARC ops).
- getter("name") / setter("name:") selector overrides.

181 example tests pass (+1). zig build test green.
2026-05-26 01:45:21 +03:00
agra
d6ef691e42 ffi M2.1(a): class-level constants 'name :: Type = expr;'
Inside a '#objc_class { ... }' block, 'name :: Type = expr;' is
accepted alongside the existing method form. Parsed as sugar for
'name :: () -> Type => expr;' — a niladic class method with an
expression body. The synthesized class method flows through the
M2.1(b) class-method pipeline: a C-ABI IMP is emitted and
registered on the metaclass.

Apple's runtime sees zero distinction — '[Cls foo]' dispatches to
our IMP regardless of source spelling. The constant form is
purely syntactic sugar; it reads better for static metadata
returns:

  SxGLView :: #objc_class("SxGLView") {
      layerClass :: Class = CAEAGLLayer.class();
  }

vs. the equivalent method form:

  layerClass :: () -> Class => CAEAGLLayer.class();

Parser change: after 'name ::' if the next token isn't '(' we
take the constant branch — parse a type expr, expect '=', parse
the value expr, expect ';'. The result is a ForeignMethodDecl
with is_static=true, empty params, return_type=Type, body=block
wrapping the expr. Pure parser-level transformation; no new AST
nodes, no new lowering passes.

150-objc-class-level-constant.sx exercises both shapes on macOS:
a primitive (s32 answer) and a pointer ('*NSObject seedClass'
— the canonical '+layerClass'-style factory return).

180 example tests pass (+1). zig build test green.

M2.1 complete: both (a) the constant form and (b) the
expression-bodied class method shape land.

Next: M2.2 — 'field: T #property(modifiers...)' synthesizes
getter/setter pairs.
2026-05-25 23:43:46 +03:00
agra
c39c8e15eb ffi M2.1(b): class methods on sx-defined #objc_class
Bodied methods without a '*Self' first param (parser marks
is_static=true) are now registered as Obj-C CLASS methods on
the metaclass.

Each such method gets:
- A synthesized FnDecl + body lowering through the existing
  M1.2 A.2 path.
- A C-ABI trampoline 'emitObjcDefinedClassStaticImp' — same
  shape as the instance trampoline but skips the __sx_state
  ivar read (no instance state) and passes only
  '__sx_default_context' (plus user args) to the sx body.
- An entry in ObjcDefinedMethodEntry with 'is_class=true'.

emit_llvm's class-pair init constructor now computes the
metaclass once up-front (via object_getClass(cls)) and shares
it between the +alloc IMP registration (M1.2 A.5) and the
M2.1(b) class-method registrations. The per-method registration
loop picks the target via 'method.is_class ? metaclass : cls'.

149-objc-class-method-static-imp.sx end-to-end on macOS:

  SxFoo :: #objc_class("SxFoo") {
      answer :: () -> s32 { return 42; }
  }

  // [SxFoo answer] via objc_msgSend → 42
  // class_getClassMethod(SxFoo, sel_answer) → non-null

Still TODO for M2.1: the (a) class-LEVEL constant form
'layerClass :: Class = CAEAGLLayer.class();' — needs parser
extension to recognize 'name :: Type = expr;' inside #objc_class
blocks, plus lazy-init-slot synthesis.

179 example tests pass (+1). zig build test green.
2026-05-25 23:40:51 +03:00
agra
0ac5ba2ccd ffi M1.3: obj.class accessor on Obj-C-class pointers
Adds a special case to lowerFieldAccess: when the field is
literally 'class' and the receiver is a pointer to an Obj-C
(or Obj-C protocol) foreign-class struct, emit
'object_getClass(obj)' instead of falling through to struct GEP.

Returns 'Class' (the M1.1 first-pass alias for *void;
parameterized Class(T) covariance is deferred to M1.1.b).

  f := SxFoo.alloc();
  cls := f.class;                       // → object_getClass(f)
  cls == objc_getClass("SxFoo".ptr);   // ok

New helper isObjcClassPointer(ty) detects 'ptr -> struct in
foreign_class_map under .objc_class / .objc_protocol'. The
check fires BEFORE the auto-deref so the runtime call sees the
opaque Obj-C pointer rather than the load'd struct stub.

148-objc-self-class-accessor.sx exercises both shapes end-to-end
against the macOS runtime: sx-defined class (SxFoo) and foreign
class (NSObject). Round-trips against objc_getClass(name).

178 example tests pass. zig build test green.

This effectively closes Month 1 — M1.0, M1.1 (first pass), M1.2,
M1.3 all done. Remaining: M1.1.b (Class(T) covariance +
instancetype), then Month 2 (declarative sugar).
2026-05-25 23:33:52 +03:00
agra
51277afadf ffi M1.2 A.7: open the dispatch gate — sx-defined class methods callable
Delete the bail at lower.zig:4407 that diagnosed sx-defined Obj-C
class dispatch as 'not yet supported'. Both foreign and
sx-defined '#objc_class' decls now flow through the same
'lowerObjcMethodCall' path — instance methods on sx-defined
classes dispatch via objc_msgSend, and the registered IMP
trampolines (M1.2 A.4b.iii) route to the sx bodies.

The runtime non-Obj-C branch (.swift_class / .swift_struct /
.swift_protocol) keeps its 'not yet supported' diagnostic;
M1.2 only addresses the Obj-C runtimes.

Constructor reorder in emit_llvm: emitObjcDefinedClassInit
runs BEFORE emitObjcClassInit. Otherwise the Phase 3.1
class-cache populator calls objc_getClass("SxFoo") before our
constructor registers the class — cache slot stored null and
'SxFoo.method()' dispatched against a null class pointer.

ffi-objc-defined-class-01-instance.sx (the integration test
from the plan) now runs the full lifecycle on macOS:

  f := SxFoo.alloc()    // synthesized +alloc IMP fires
  f.bump()              // dispatch → IMP trampoline → sx body
  f.bump()              // state persists across calls
  f.bump()
  f.get()               // → 3
  release_fn(f, sel_release)  // synthesized -dealloc fires

The user declares 'alloc :: () -> *SxFoo;' bodyless to give the
synthesized +alloc IMP a typed contract at sx call sites —
same convention as foreign classes today.

M1.2 complete: A.0 A.1 A.2 A.3 A.4 A.4b.i A.4b.ii A.4b.iii
A.5 A.6 A.7. End-to-end class-synthesis foundation works.

177 example tests pass (+1 from the integration test). zig
build test green.
2026-05-25 23:29:55 +03:00
agra
c107aa4e21 ffi M1.2 A.6: synthesized -dealloc IMP + [super dealloc] chain
For every sx-defined #objc_class, emit a C-callconv -dealloc IMP
that runs at refcount-zero. Frees the sx state struct, nils the
ivar, then chains to [super dealloc] so NSObject's runtime
cleanup (object_dispose, associated-object teardown, KVO, etc.)
runs as usual.

  -dealloc IMP (self: id, _cmd: SEL) -> void
      state = object_getIvar(self, load @__<Cls>_state_ivar)
      free(state)                              // free(NULL) is safe
      object_setIvar(self, ivar, NULL)
      sup = alloca { receiver: *void, super_class: *void }
      sup.receiver    = self
      sup.super_class = load @__<Cls>_class
      sel_dealloc = sel_registerName("dealloc")
      objc_msgSendSuper2(&sup, sel_dealloc)
      return

Two new per-class globals:
- '__<Cls>_class' : *void — populated by emit_llvm's
  class-pair init constructor with the freshly-allocated Class
  pointer (after objc_registerClassPair).
- The existing '__<Cls>_state_ivar' is also consulted to find
  the state struct.

The -dealloc IMP is registered on the class itself (instance
method) via class_addMethod with encoding 'v@:'. emit_llvm
ALSO stores cls_val into '__<Cls>_class' so the trampoline
can build the objc_super struct.

internStringConstantGlobal helper added to lower.zig — interns
C strings as [N:0]u8 globals with byte-level aggregate inits.
Used here for the 'dealloc' selector string.

147-objc-class-dealloc-roundtrip.sx verifies end-to-end on
macOS: alloc + release fires the IMP, and a second alloc/release
cycle proves runtime state isn't corrupted. class_getMethod-
Implementation confirms the IMP is registered.

176 example tests pass (+1). zig build test green.

Still gated: sx-side 'obj.method()' calls bail at lower.zig:4407
with the existing diagnostic. A.7 opens the gate — last sub-step
of M1.2.
2026-05-25 23:25:13 +03:00
agra
a1736f3213 ffi M1.2 A.5: synthesized +alloc IMP + ensureCRuntimeDecl helper
For every sx-defined #objc_class, emit a C-callconv +alloc IMP
that the Obj-C runtime calls when '[Cls alloc]' fires (from sx
code, UIKit instantiation, Info.plist principal class, etc.):

  +alloc IMP (cls: Class, _cmd: SEL) -> id
      instance = class_createInstance(cls, 0)
      state    = malloc(STATE_SIZE)
      memset(state, 0, STATE_SIZE)
      object_setIvar(instance, load(@__<Cls>_state_ivar), state)
      return instance

STATE_SIZE = max(typeSizeBytes(state struct), 1) — always at
least one byte so the ivar is never null after +alloc returns.

The IMP is registered on the METACLASS (class methods live there
— every Class object's isa points to the metaclass) in emit_llvm's
class-pair init constructor:

  metaclass = object_getClass(cls)
  sel_alloc = sel_registerName("alloc")
  class_addMethod(metaclass, sel_alloc, alloc_imp, "@@:")

That override wins over NSObject's default +alloc; runtime
instantiations get the __sx_state ivar bound automatically.

Per-instance allocator binding (the plan's full design — store
the Allocator value in the state struct so -dealloc frees through
the same one) is deferred. libc malloc/free is fine for v1; we'll
upgrade once Month 4's autoreleasepool + ARC ops shake out.

REFACTOR: collapsed five duplicate 'get<Name>Fid' helpers and
their cache fields (object_getIvar, object_setIvar,
class_createInstance, malloc, memset) into a single
'ensureCRuntimeDecl(name, params, ret) -> FuncId'. The helper
checks for an existing decl by name first (avoids the
'class_createInstance.1' duplicate-symbol crash when stdlib's
'#foreign' decl is already in the module). One helper instead
of one-per-function = ~150 lines deleted.

object_getIvar / object_setIvar added to stdlib std/objc.sx
so user code can use them too (146 exercises object_getIvar
to verify __sx_state was bound to a non-null state pointer
after +alloc).

146-objc-class-alloc-roundtrip.sx end-to-end against macOS:
'[SxFoo alloc]' returns non-null AND object_getIvar(instance,
__sx_state) returns the state ptr. Real Obj-C runtime, no
mocks.

175 example tests pass (+1). zig build test green.
2026-05-25 23:17:30 +03:00
agra
87572579b4 ffi M1.2 A.4b.iii: class_addMethod wires IMPs to the Obj-C runtime
For each instance method on a sx-defined '#objc_class', the
class-pair init constructor now:

  sel = sel_registerName("selector_string")
  imp = @__<Cls>_<method>_imp                  (M1.2 A.4b.ii)
  class_addMethod(cls, sel, imp, "<encoding>")

before objc_registerClassPair. The IMP trampoline (A.4b.ii)
already bridges C-ABI -> sx body. With registration in place,
'objc_msgSend(obj, sel_bump)' now routes to the trampoline,
which reads __sx_state ivar and forwards to '@<Cls>.<method>'.

To get selector + type-encoding strings out of lower.zig and
into emit_llvm, ObjcDefinedClassEntry gains a 'methods' slice:

  pub const ObjcDefinedMethodEntry = struct {
      sel: []const u8,       // mangled selector (M1.2 A.1's deriveObjcSelector)
      encoding: []const u8,  // type encoding (M1.2 A.1's objcTypeEncodingFromSignature)
      imp_name: []const u8,  // C-callconv trampoline symbol
  };

registerObjcDefinedClassMethods populates this when it declares
each method's body function; Module.setObjcDefinedClassMethods
attaches the slice to the cache entry by name. Static (class-
side) methods are skipped — A.4b only covers instance methods;
class-method hooks like '+layerClass' land in M2.1.

emit_llvm reads entry.methods and emits class_addMethod inside
the per-class init block, before objc_registerClassPair (the
runtime locks the method list at register time on some SDK
versions).

145-objc-class-method-dispatch.sx verifies end-to-end:
class_getMethodImplementation(SxFoo, sel_registerName("bump"))
returns non-null after main starts. Both niladic ('bump') and
single-arg ('add:') selectors checked.

Still gated (A.7): sx-side 'obj.bump()' calls. The dispatch
gate at lower.zig:4407 hasn't opened — A.5 (+alloc) and A.6
(-dealloc) need to land first so the integration test
ffi-objc-defined-class-01-instance.sx (full state round-trip)
can exercise the full lifecycle.

174 example tests pass (+1 from 145). zig build test green.
2026-05-25 22:58:20 +03:00
agra
c0b338eaa4 ffi M1.2 A.4b.ii: emit C-ABI IMP trampolines (dead code pending class_addMethod)
For each bodied instance method on a sx-defined #objc_class,
emit a C-callconv trampoline function '__<Cls>_<method>_imp':

  void __SxFoo_bump_imp(ptr obj, ptr _cmd, ...user_args) {
      ivar  = load @__SxFoo_state_ivar
      state = object_getIvar(obj, ivar)
      call @SxFoo.bump(__sx_default_context, state, ...user_args)
      ret
  }

The trampoline bridges the Obj-C runtime's IMP calling convention
('id self, SEL _cmd, ...args' as C ABI) to the sx body's
default-callconv shape ('__sx_ctx ptr, state ptr, ...user_args').
Implicit context comes from '&__sx_default_context'; the body
keeps its sx-side personality intact and can use 'self.field'
through the substituted state-struct pointer (M1.2 A.2b + A.3).

New helpers in lower.zig:
- 'getObjcObjectGetIvarFid' lazily declares object_getIvar.
- 'emitObjcDefinedClassImps' + 'emitObjcDefinedClassImp' walk the
  cache and synthesise each trampoline.
- 'lookupGlobalIdByName' for finding the per-class ivar handle
  global. Linear scan — same N-is-small rationale as the other
  Obj-C caches.

Dead code at this commit: the trampolines exist in the module
but no class_addMethod call registers them with the runtime.
'objc_msgSend(obj, sel_bump)' would still fall through to the
parent class (NSObject 'doesNotRecognizeSelector:') today.
A.4b.iii wires up class_addMethod in emit_llvm's class-pair-init
constructor — that's when the trampolines come alive.

142's IR snapshot refreshed to show the trampoline.

173 example tests pass. zig build test green.
2026-05-25 22:52:34 +03:00
agra
c2178c062b ffi M1.2 A.4b.i: __sx_state ivar registration
Class-pair init constructor now registers a single hidden ivar
on each sx-defined class:

  class_addIvar(cls, "__sx_state", 8, 3, "^v")

before objc_registerClassPair. After the class is registered,
the constructor calls class_getInstanceVariable to fetch the
runtime Ivar handle and stores it in a per-class global
'__<ClassName>_state_ivar : *void'. Trampolines (A.4b.ii) will
read this global to 'object_getIvar' the state struct pointer.

lower.zig declares the per-class global at scan time
(declareObjcDefinedStateIvarGlobal) so emit_llvm finds it by
name when populating. Encoding '^v' = void* (a generic pointer
— the runtime treats it as opaque storage). log2 alignment = 3
for 8-byte pointer alignment on 64-bit.

144-objc-class-ivar-registration.sx exercises the round-trip:
after main starts, class_getInstanceVariable(SxFoo, "__sx_state")
returns non-null. Runs against the real Obj-C runtime on macOS.

142's IR snapshot refreshed to include the new constructor body
(class_addIvar + class_getInstanceVariable + ivar-global store).

173 example tests pass (+1 from 144). zig build test green.
2026-05-25 22:23:59 +03:00
agra
b98a22e3f9 ffi M1.2 A.4: emitObjcDefinedClassInit class-pair registration
For every sx-defined '#objc_class', emit a module-init constructor
that registers the class with the Obj-C runtime at module load.
Pattern mirrors the Phase 3.1 emitObjcClassInit companion:
'@llvm.global_ctors' + ORC-JIT main injection.

Constructor body, per cache entry:

  super = objc_getClass("<ParentName>")  // default NSObject
  cls   = objc_allocateClassPair(super, "<ClassName>", 0)
  objc_registerClassPair(cls)

Parent is read from the foreign_class_decl's '.extends' member;
absent ⇒ NSObject (matches M1.2 A.0 spec). Class-name strings
go through new emitPrivateCString helper that mirrors the
selector-init / class-init shape.

Two new small helpers extracted while we were here:
- lazyDeclareCRuntime — declare-once extern wrapper for Obj-C
  runtime APIs.
- appendModuleCtor — append-or-create global_ctors + ORC-JIT
  injection, factored out of emitObjcClassInit.

143-objc-class-registration.sx exercises the round-trip on
macOS: after main starts, objc_getClass("SxFoo".ptr) returns
non-null. Runs against the real Obj-C runtime.

142's IR snapshot updated — the constructor + ctors metadata
are now part of the expected shape.

DEFERRED (A.4b): method-IMP registration (class_addMethod with
a C-ABI trampoline that reads __sx_state ivar and calls the sx
body). DEFERRED (A.5+): synthesized +alloc / -dealloc IMPs and
the '__sx_state' ivar setup.

172 example tests pass (+1 from 143). zig build test green.
2026-05-25 22:14:31 +03:00
agra
659cdc2276 ffi M1.2 A.2c + A.3: eager body lowering + self.field via state struct
Adds Pass 4b 'lowerObjcDefinedClassMethods' to lowerRoot: after
scan, walk objc_defined_class_cache and force-lower each bodied
instance method. The Obj-C runtime invokes these via the IMP
pointers wired up in A.4 — no sx-side call path drives lazy
lowering, so we trigger it here. Mirrors the JNI eager-lower
pattern in Pass 5.

Bug fix: lazyLowerFunction has its OWN inline body-lowering
path (separate from lowerFunction) that re-resolves param types
at line 1025. It was running without current_foreign_class set,
so '*Self' fell through to the type_bridge fallback and got
interned as a 0-field struct named 'Self' — body's
'self.counter' GEP'd into '{}' and LLVM verification rejected.
Fix: set current_foreign_class at the top of lazyLowerFunction
via the same lookupObjcDefinedClassForMethod path lowerFunction
uses. Save+restore via defer.

A.3 ('self.field access via the ivar') falls out for free —
'*Self' resolves to '*__SxFooState' so 'self.counter' is a
plain struct field access. IR snapshot in
142-objc-class-method-lowering.ir shows the round-trip:

    define internal void @SxFoo.bump(ptr, ptr self) {
        %gep = getelementptr inbounds { i32 }, ptr %self, 0, 0
        %v = load i32, ptr %gep
        store i32 (%v + 1), ptr %gep
        ret void
    }

171 examples pass (+1 from 142); zig build test green.

Still gated: Obj-C runtime dispatch (A.7) — sx-side
'f.bump()' calls bail at lower.zig:4407 with the existing
diagnostic. IMP-trampoline emission (the C-ABI shim that bridges
'objc_msgSend' → this body) lands in A.4 alongside class-pair
init.
2026-05-25 22:08:23 +03:00
agra
ae1072d415 ffi M1.2 A.2b: register sx-defined #objc_class methods + *Self substitution
Bodied instance methods on a sx-defined '#objc_class("Cls") { ... }'
declaration are now registered in fn_ast_map under '<Cls>.<method>'
and declared in the IR with their *Self params substituted to
the hidden state-struct type (M1.2 A.2a).

registerObjcDefinedClassMethods walks the foreign_class_decl's
members, synthesizes an FnDecl from each ForeignMethodDecl (zipping
params + param_names), and feeds it through declareFunction with
current_foreign_class temporarily pinned so resolveTypeWithBindings
substitutes Self → __SxFooState.

resolveTypeWithBindings now treats type_expr 'Self' as a contextual
alias: when current_foreign_class points to a sx-defined Obj-C
class, the substitution returns objcDefinedStateStructType(fcd).
Other Self contexts (protocols, JNI super, foreign-class member
type resolution) are untouched — the check filters on (!is_foreign
and runtime == .objc_class).

lowerFunction also sets current_foreign_class for the duration of
the body lowering when the name is qualified <Cls>.<method> and
Cls is in objc_defined_class_cache. Save+restore via defer so
nested calls round-trip cleanly.

Verification (manual): 'sx ir' on an sx-defined class shows
'declare void @SxFoo.bump(ptr, ptr)' — two args = implicit
__sx_ctx + the state-struct pointer (correct *Self substitution).
Body emission happens lazily; A.2c will trigger it eagerly so
the IMP trampoline (A.4) can reference it.

170 example tests + zig build test green.
2026-05-25 21:59:23 +03:00
agra
7b98b3ae78 ffi M1.2 A.2a: objcDefinedStateStructType helper
Builds (and interns) the hidden sx-state struct type for an
sx-defined '#objc_class'. Layout:

    __<ClassName>State {
        user_field_0,
        user_field_1,
        ...
    }

This struct is what the runtime's '__sx_state' ivar points at —
separate from the Obj-C object itself, which stays opaque. The
sx method bodies will operate on '*__SxFooState' (after '*Self'
substitution in A.2b) so 'self.field' resolves to a plain struct
field access — A.3's 'free if types align' premise.

M1.2 A.5 will prepend '__sx_allocator: Allocator' so dealloc can
free through the per-instance allocator. Field-by-name access
stays correct across the future repositioning.

Methods / '#extends' / '#implements' members are ignored — only
'.field' contributes. Three unit tests pin: typical-field case,
empty-class case, mixed-member case.

Dead code at this commit — helper isn't called yet. A.2b (body
lowering with '*Self' substitution) wires it in. 170 example
tests + zig build test green.
2026-05-25 21:51:07 +03:00
agra
6cc016cd4f ffi M1.2 A.1: objcTypeEncodingFromSignature helper + encoding table
Derives Apple's runtime type-encoding string from an IR method
signature. Called by class_addMethod(cls, sel, imp, types) when
M1.2 A.4+ synthesise IMPs for sx-defined classes.

Layout: <ret> @ : <param0> <param1> ...   — @ is the receiver,
: is _cmd. Caller passes user-declared params AFTER stripping
'self: *Self'.

Encoding table:
  v=void  B=bool  c=s8/BOOL  s=s16  i=s32  q=s64
  C=u8    S=u16   I=u32      Q=u64  f=f32  d=f64
  @=foreign Obj-C class ptr        #=Class  :=SEL
  *=[*]u8 (C string)               ^v=any other ptr

bool (sx i1) maps to 'B' (C99 _Bool); s8 to 'c' (Apple's BOOL).
Foreign-class pointers detected via foreign_class_map lookup on
the pointee struct name. Other pointers fall to ^v — encoding is
metadata, not ABI, so conservative is safe.

Struct / slice / closure / etc. BAIL via diagnostic
(ObjcEncodingUnsupported) rather than silently mis-encoding, per
CLAUDE.md rejected-patterns rule. Future passes will widen the
table as new shapes show up in real IMPs.

Dead code at this commit — helper isn't called yet. Three unit
tests in src/ir/lower.test.zig pin the primitive / pointer /
Obj-C-class-pointer encodings before A.2 wires the helper in.

170 example tests + zig build test green.
2026-05-25 21:43:53 +03:00
agra
61a2593020 ffi M1.2 A.0: objc_defined_class_cache + scan-pass registration
Adds an insertion-ordered cache on Module for sx-defined Obj-C
classes — every '#objc_class("Cls") { ... }' declaration WITHOUT
'#foreign'. registerForeignClassDecl appends the entry alongside
its existing foreign_class_map insert; lookup helper available
via Module.lookupObjcDefinedClass.

  ObjcDefinedClassEntry { name, *const ast.ForeignClassDecl }

The pointer back into the AST lets later passes (M1.2 A.1+) walk
'members' for fields / methods / '#extends' / '#implements'
without duplicating that data on the entry. Insertion order
matters because class-pair init constructors (A.4) must register
parent classes before children — 'objc_allocateClassPair(super,
...)' resolves super by lookup.

Infrastructure only — no observable behavior change. The cache
is populated but not yet read; A.1+ start pulling from it. 170
example tests + zig build test green.
2026-05-25 21:37:36 +03:00
agra
86c1127c46 ffi M1.0 (3/3): accept '=>' body in '#objc_class' member methods
Extends parseForeignClassDecl ([src/parser.zig:1262]) with an
arrow arm that mirrors the existing parseFnDecl shape — single-
expression body wrapped in a one-statement block so downstream
lowering sees the same AST as a brace-body method.

Closes the M1.0 surface: '=> expr;' is now valid for top-level
functions, struct methods, AND '#objc_class' member methods.
The sx-defined class lowering (A.7 dispatch gate) is still gated,
so 140-expression-bodied-objc-method.sx exercises parse-only —
the body is reachable but the method is never invoked. When M1.2
lights up sx-defined class instantiation, the arrow-body form
will flow through unchanged.

169 examples pass (+1 from 140 now green); zig build test green.
2026-05-25 21:18:09 +03:00
agra
2b717d9b38 ffi: resolve foreign-class member types through Self substitution (issue-0043)
`inferExprType` for a chained call `Cls.static().instance(...)` never
looked the inner call's foreign-class declaration up, so the outer
dispatch saw a `.s64` receiver, the `foreign_class_map.get(...)` lookup
missed, and lowering emitted `error: unresolved 'method'`. The macOS
target appeared to work because `inline if OS == .ios { ... }` strips
the gated body before lowering — eliding every call that would have
exercised the broken path.

The "lazy-lower" framing in the original issue file was a red herring.

Fix in `src/ir/lower.zig`:

1. `inferExprType` for `.call` with `.field_access` callee now checks
   `foreign_class_map` for both shapes — `Cls.static_method(args)` (object
   identifier matches a foreign-class alias, look up static members) and
   `inst.instance_method(args)` (receiver is a pointer to a foreign-class
   struct, look up non-static members).
2. New helpers `resolveForeignMethodReturnType` and
   `resolveForeignClassMemberType` substitute `*Self` / `Self` to the
   foreign-class struct so a `*Self` return doesn't synthesize a phantom
   `Self`-named struct that future dispatches can't resolve.
3. The Obj-C lowering paths (`lowerObjcMethodCall`, `lowerObjcStaticCall`)
   route through the same helper for `ret_ty` so the IR Ref's type matches
   what `inferExprType` reports.

Regression test at `examples/138-foreign-class-chained-dispatch.sx`
exercises NSObject's `+alloc` / `-init` chain in both shapes —
`*NSObject` return then `*Self` return, and `*Self` then `*Self`. Runs
on the host (macOS) for live exercise; non-macOS hosts fall through to
a stub matching the expected output.

This unblocks Phase 3.2 C4/C5 — the `UIWindow.alloc().initWithWindowScene(scene)`
pattern that surfaced the bug is the cluster's bread-and-butter shape.

167/167 example tests; chess builds clean on macOS, iOS-sim, Android.
2026-05-25 17:52:53 +03:00
agra
572ab12142 ffi 3.2 A2: implement #selector("explicit:string") override
Make-green half of the cadence step started in A1. Wires the
`#selector` directive end-to-end:

- Lexer token `hash_selector` at src/token.zig + lookup row in
  src/lexer.zig.
- AST field `selector_override: ?[]const u8 = null` on
  `ForeignMethodDecl` (src/ast.zig).
- Parser block in src/parser.zig that mirrors
  `#jni_method_descriptor` — both occupy the same slot after the
  optional `-> ReturnType` and before the body/terminator. Not
  mutually exclusive at parse time.
- LSP semantic-token list (src/lsp/server.zig) updated.
- Lowering: `deriveObjcSelector` returns
  `{ sel, keyword_count, is_override }`. When `is_override` is true,
  the selector string is the user's literal and `keyword_count` is
  the colon count in that literal. Both `lowerObjcMethodCall` and
  `lowerObjcStaticCall` use the result.

Diagnostic policy when override colon-count ≠ call arity:

- Default mangling path: stays an error (`.err`). The user can fix
  the sx-side name to produce the right keyword count.
- Override path: downgrades to a warning (`.warn`). Rationale:
  Obj-C's `objc_msgSend` doesn't validate colon-vs-arg the way JNI's
  `GetMethodID` validates the descriptor — the runtime dispatches
  regardless and the wrong-arity case becomes silent calling-
  convention corruption. The compiler is the last line of defense
  for this typo class, but the warning preserves the override's
  escape-hatch character (deliberate mismatches still proceed).

Snapshot for `examples/ffi-objc-dsl-06-selector-override.sx` flips
from the pre-3.2 parser-error to working output:

  static override non-null: true

The mismatch diagnostic text in
`examples/ffi-objc-dsl-04-mismatch.sx`'s snapshot is updated to
drop the "once that lands (3.2)" phrasing now that 3.2 is here.

165/165 example tests.
2026-05-25 17:00:23 +03:00
agra
56414407fc ffi: drop static keyword on foreign-class methods; param type discriminates
`static name :: ...` was redundant — instance methods always declare
`self: *Self` as their first param by convention. The parser now derives
`is_static` from the first param's TYPE: if it's `*Self` the method is
an instance method; anything else (including no params at all) is a
class method. Removes a token from the surface, keeps the dispatch
behavior identical.

The receiver param's NAME doesn't matter — only its type. Calling the
first param `this`, `me`, `receiver`, etc. is fine as long as the type
is `*Self`. This mirrors how the rest of sx handles receiver dispatch.

Migration of every site that used the keyword:

- `library/modules/platform/android.sx` — `SurfaceView.new(ctx)`.
- `examples/ffi-jni-class-03-static.sx` — `Math.abs(n)`.
- `examples/ffi-jni-main-03-ctor.sx` — `SurfaceView.new(ctx)` in the
  `#jni_main` body.
- `examples/ffi-objc-dsl-05-static.sx` — NSObject's `.class()` /
  `.description()`.

164/164 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
2026-05-25 16:32:32 +03:00
agra
8406cc1fed ffi 3.1: Cls.static_method(args) lowers to objc_msg_send on the class object
Implementation half of the Phase 3.1 cadence step.
`lowerForeignStaticCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcStaticCall` helper
that loads the class object from a module-scoped cached slot (populated
once per module via `objc_getClass`) and dispatches `objc_msg_send`
with the same selector-mangling as Phase 3.0's instance dispatch.

Three pieces:

1. `Module.objc_class_cache` — parallel to `objc_selector_cache`,
   insertion-ordered list of (class_name, slot_GlobalId) so the
   constructor that calls `objc_getClass` per slot at module load
   is deterministic. `lookupObjcClass` / `appendObjcClass` accessors.
2. `internObjcClassObject` in lower.zig — get-or-create a
   `OBJC_CLASSLIST_REFERENCES_<Cls>` global pointer; matches clang's
   naming convention. `lowerObjcStaticCall` reuses
   `deriveObjcSelector` from 3.0 for the selector, loads the class
   slot, and emits `objc_msg_send(class_obj, sel, args)`.
3. `emitObjcClassInit` in emit_llvm.zig — companion to
   `emitObjcSelectorInit`. Walks `objc_class_cache`, synthesizes a
   constructor `__sx_objc_class_init` that calls `objc_getClass(name)`
   per slot, registers in `@llvm.global_ctors` for AOT (extending the
   existing array if the selector init already created it), and
   injects a direct call into main's prelude after any prior init
   calls so the ORC JIT path runs it too.

Surface form is `.` (`NSObject.class()`) matching JNI's `Alias.new(...)`
convention rather than the plan's notional `::` — avoids extending the
parser for a new postfix operator with no other use case.

Test `examples/ffi-objc-dsl-05-static.sx` exercises NSObject's
`+class` and `+description` class methods via the new syntax, asserts
both return non-null. NSObject is always available at module-load,
unlike runtime-created test classes that wouldn't exist yet when
the class-init constructor runs.

164/164 tests; chess builds + runs clean on all three platforms.
2026-05-25 16:23:24 +03:00
agra
53fe73acda ffi 3.0: inst.method(args) DSL dispatch on #objc_class receivers
Implementation half of the cadence step started in the previous commit.
`lowerForeignMethodCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcMethodCall` helper
that derives the Obj-C selector from the sx method name and lowers to
`objc_msg_send` against the cached SEL slot (same intern path as
explicit `#objc_call`).

Default selector mangling (matches clang's keyword-method convention):
- Niladic (arity 0 excluding self): name verbatim. `length()` → "length".
- Arity ≥ 1: split the sx method name on `_`; each piece becomes a
  keyword with a trailing `:`. `addObject(o)` → "addObject:";
  `combine_and(a, b)` → "combine:and:";
  `initWithFrame_options(f, o)` → "initWithFrame:options:".

Arity validation: keyword count (pieces from the `_`-split) must equal
call-site arity excluding self. Mismatch diagnoses at the call site
with a hint pointing at the forthcoming `#selector("...")` override
(Phase 3.2) for selectors that don't fit the underscore-split rule.

Mangling helper `deriveObjcSelector` and dispatch helper
`lowerObjcMethodCall` sit alongside `lowerForeignMethodCall`. The
existing fall-through diagnostic for non-JNI/non-Obj-C runtimes
remains for Swift (Phase 4 territory).

Tests `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,
04-mismatch}.sx` snapshots flip from the pre-3.0 bail diagnostic
(exit=1) to working output (exit=0 for cases 01-03) and the specific
keyword-count mismatch diagnostic for case 04. Each test follows the
established pattern from `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare a matching `#objc_class`, invoke the DSL
form. 163/163 tests; chess unaffected (JNI dispatch path untouched).
2026-05-25 16:10:22 +03:00
agra
071352e655 mem: remove resolveType(null) → .s64 silent fallback
CLAUDE.md REJECTED PATTERNS forbids silent default returns where the
"reasonable-looking" value happens to match one common case (s64 = 8
bytes = pointer-sized on the host) and is silently wrong everywhere
else. `resolveType(null) → .s64` was exactly this shape: a top-level
`g_pi := 3.14;` was silently typed as `s64`, producing a wrong-typed
slot and the wrong runtime value.

`resolveType` now takes a non-optional `*const Node`. Twelve callers
were classified:

- Six were already guarded by `if (x.type_annotation != null)` blocks
  — the null branch was unreachable. Cleaned up to optional-payload
  syntax (`if (cd.type_annotation) |ta|`) so the always-non-null path
  is obvious from the type.
- Two (`#objc_call` / `#jni_call` return types) pass `FfiIntrinsicCall.
  return_type`, which is `*Node` (not optional) in the AST — the
  silent fallback couldn't be reached there either.
- One (top-level `var_decl` at lower.zig:630) DID legitimately receive
  null when the user omitted both annotation and initializer typing.
  Now mirrors `lowerVarDecl`'s local-scope behavior: explicit
  annotation → resolveType; no annotation → `inferExprType` from the
  initializer; neither → diagnose with a real error message.
- One (`lowerComptimeGlobal`, fixed in commit 82e7b04 alongside
  Phase 1.4) already infers from the comptime expression.
- Two (JNI super-call / JNI method return type) were already
  hand-rolled with `if (rt) |t| resolveType(t) else .void`.

Regression at `examples/137-toplevel-var-type-inference.sx`: `g_count
:= 42;` / `g_pi := 3.14;` / `g_flag := true;` at module scope. Pre-fix
`g_pi` got silently typed as `s64` and printed `0` or garbage; now it
prints `3.140000`. 159/159 example tests + chess clean.
2026-05-25 15:59:32 +03:00
agra
179310d62b mem: Phase 1.4a — fat-pointer aggregates from #run serialize via host memory
The Phase 1.4 serializer left a silent malformed-const case: when the
interp evaluated a `#run` returning a string (or anything with a fat
pointer inside), the data field came in as a `.int` holding a libc
host address. `LLVMConstInt(ptr_type, addr, 1)` happily emitted `i0 0`
in the static const, and the runtime segfaulted on the first read.

Phase 1.4a closes this for string and slice destinations. The signature
of `valueToLLVMConst` now takes the IR `TypeId` (instead of just the
LLVM type) and a borrowed `*Interpreter`. A new helper
`serializeAggregateValue` splits on the IR type:

- `string` / `slice` (fat pointer `{data, len}`): extract `len`, read
  that many bytes from the data field's address (via `interp.heapSlice`
  for `heap_ptr`, via a new `readHostBytes` for `byte_ptr` / `.int`,
  via slice indexing for string literals). Emit the bytes as a private
  global byte array using the existing `emitConstStringGlobal`. The
  fat-pointer aggregate's data ptr resolves to the byte array's address.
- `struct`: walk the IR field types in lockstep with the value's
  fields; recurse with each declared field TypeId. This replaces the
  old LLVM-type-walk via `LLVMStructGetTypeAtIndex` which couldn't tell
  string-typed fields from generic ptr fields.
- `array`: walk with the element TypeId.

The remaining `.int → ptr` trap (a host address landing in a bare ptr
field outside a fat pointer) now bails loudly with a named diagnostic
identifying it as Phase 1.4a heap-walk follow-up territory. No
practical trigger in-tree, so deferred.

`Interpreter.heapSlice` promoted from package-private to `pub` so
the serializer can read interp-managed heap data.

Regression: `examples/136-comptime-string-global.sx` —
`GREETING :: #run build_greeting();` where `build_greeting` returns
`concat("hello", " world")`. Runtime prints `greeting = 'hello world'`
and `greeting.len = 11`. Pre-1.4a this segfaulted on the first read.

158/158 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
2026-05-25 15:45:33 +03:00
agra
b710a0a42a lang: xx <lvalue> borrows the operand's storage instead of heap-copying
`xx <struct-typed local>` used to heap-copy the value through context.allocator.
The protocol value's `ctx` pointed at the heap copy; the original local was
left behind, untouched. Mutations through the protocol never reached the
original, and direct reads of the original never saw protocol mutations.
Two-fork bug, silent, easy to write by mistake.

New rule (Option 3 in the discussion):

- `xx <lvalue>` — identifier, field access, index expression, deref —
  borrows the operand's storage. No heap copy, no `free` needed.
- `xx <rvalue>` — struct literal, function-call result, arithmetic, etc. —
  heap-copies through context.allocator. Unchanged from today.
- `xx @ptr` and `xx <pointer-typed value>` — borrows the pointee. Unchanged.

Single switch in `buildProtocolErasure` ([lower.zig:10334](src/ir/lower.zig#L10334))
gated by a new `isLvalueExpr` helper ([lower.zig:10322](src/ir/lower.zig#L10322)).
Struct-typed operand: if the AST shape is identifier/field/index/deref,
emit `lowerExprAsPtr(operand_node)` and skip the heap-copy; otherwise
keep the alloca-store-heap_copy path.

specs.md §3 ownership table extended to three rows (rvalue, lvalue,
pointer) with examples and rationale per row.

Regressions:

- `examples/130-xx-value-routes-through-context-allocator.sx` — the
  Phase 1.1 witness for heap-copy-via-context-allocator. Previous shape
  (`xx <local-value>`) is now a borrow under Option 3 and no longer
  exercises the heap-copy path. Rewritten to use a struct literal
  (`xx ByValue.{...}`) which still heap-copies through context.allocator
  — Tracer.count = 1 as before.
- `examples/135-xx-lvalue-borrows.sx` — new test. Dereferences a
  TrackingAllocator into a stack value, does `xx tracker` inside a
  push Context, and asserts alloc_count/dealloc_count on the LOCAL go
  up. Under old semantics this would have stayed at 0 (heap copy got
  the increments, local stayed stale).

157/157 example tests pass; chess clean on macOS / iOS sim / Android
(`tools/verify-step.sh` ran green immediately before this work).
2026-05-25 15:23:13 +03:00
agra
82e7b04cca mem: Phase 1.4 — serialize every interp Value variant for #run globals
`valueToLLVMConst` in emit_llvm previously handled int / float / boolean
and collapsed everything else into `LLVMConstNull(ty)`. A `#run` returning
a struct, string, function pointer, or anything aggregate produced a
zero-initialized global silently — the comptime result was computed by
the interp, then thrown away when emit_llvm couldn't represent it.

Replaced with a real walk:

- int / float / boolean — as before.
- null_val — `LLVMConstNull`.
- void_val / undef — `LLVMGetUndef`.
- func_ref — `func_map` lookup (already populated for the implicit-Context
  static initializer of `__sx_default_context`).
- string — `emitConstStringGlobal`, returns a pointer to the byte array.
- aggregate — recurse field-by-field. Struct: walk
  `LLVMStructGetTypeAtIndex` and emit `LLVMConstNamedStruct`. Array:
  walk `LLVMGetElementType` and emit `LLVMConstArray2`.

The remaining variants (heap_ptr, byte_ptr, slot_ptr, closure, type_tag)
bail loudly with a `std.debug.print` carrying the global name — per
CLAUDE.md REJECTED PATTERNS, no more silent unimplemented arms. heap_ptr
serialization requires threading the IR `TypeId` so the heap content can
be walked recursively; deferred to Phase 1.4a alongside cycle detection.
The call site at emit_llvm.zig:676 now passes `global.name` so the
diagnostic locates the offending `#run` binding.

Type-inference fix at the binding site: `NAME :: #run expr;` with no
annotation used to default to `s64` via `resolveType(null) -> .s64`,
so even a successful Phase 1.4 serialization would emit `{0, 0}` —
the global's destination type was wrong. `lowerComptimeGlobal` now
calls `inferExprType(expr)` when no annotation is given, so the
inferred type matches the comptime function's return type. The
broader `resolveType(null)` fallback is left in place for other
callers — flagged in the MEM checkpoint as a follow-up audit.

Regression: `examples/134-comptime-aggregate-global.sx` exercises
`POINT :: #run make_point()` returning a `Point { x: s32, y: s32 }`.
Both interp (`sx run`) and codegen (`sx build`) now print
`POINT.x = 7 / POINT.y = 13` instead of `0 / 0`. 156/156 example
tests pass; chess unchanged.
2026-05-25 15:01:58 +03:00
agra
72593db953 mem: List(T) mutations gain optional alloc: Allocator = context.allocator
The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.

Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.

Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.

Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:

- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
  hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
  `ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
  render would otherwise leak resources into the frame arena).

Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.

Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
  The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
  through context.allocator" section with the `parent_allocator`
  capture template and the list of existing examples to mirror.

155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
2026-05-25 14:41:17 +03:00
agra
b263704664 mem: delete .heap_alloc/.heap_free IR ops + the silent libc-malloc escape
allocViaContext used to fall back to a direct `.heap_alloc` (libc
malloc) when `Context` wasn't registered — i.e. when the program
didn't import std.sx. That was a silent escape hatch: a program could
appear to allocate fine without a `Context`, sidestepping protocol
dispatch entirely. Same shape as the matchContextAllocCall trap we
removed, just in a different code path.

Now: every site that needs `Context` emits a clear diagnostic when
the type isn't in scope, pointing the user at the required import.

- `allocViaContext`: the three fallback branches (no implicit_ctx, no
  Context type, malformed Context struct) all call the new
  `diagnoseMissingContext("heap allocation")` and return a
  placeholder. Codegen no longer emits libc malloc as the silent
  no-import path.
- `lowerPush`: the no-Context branches used to silently drop the
  push and just lower the body. Now diagnose first, then lower
  (keeping the body's other diagnostics flowing).
- `lowerIdentifier` for "context": used to silently fall through to
  `global_names.get("context")` (which would emit an unresolved
  identifier with no actionable hint). Now diagnose with the
  required-import message.

With every consumer gone, the `.heap_alloc` and `.heap_free` IR ops
are deleted entirely:

- `inst.zig`: drop the Op variants.
- `interp.zig`: drop the execInst arms.
- `emit_llvm.zig`: drop the arms (the `getOrDeclareMalloc/Free`
  helpers stay — they're still used by the foreign-decl path for
  user-level `malloc`/`free` foreign bindings).
- `print.zig`: drop the printers + the isVoidOp arm.
- `emit_llvm.test.zig`: drop the unit test (op no longer exists).

155/155 example tests pass. Unit tests green. Chess green on macOS /
iOS sim / Android. A program that doesn't import std.sx and tries to
use `context.allocator.alloc` or `push Context.{}` or the `context`
identifier now gets a real error:

  error: heap allocation requires the Context type — add
  `#import "modules/std.sx";` (or a module that imports it)

Closes the last silent allocation-protocol escape.
2026-05-25 12:49:26 +03:00