Commit Graph

141 Commits

Author SHA1 Message Date
agra
93adde5a3d ffi 2.16a xfail: #jni_env(env) { body } block-form intrinsic
`#jni_env(synth_env) { ... }` should parse as a block-scoped env
intrinsic, today the lexer doesn't know the directive and the parser
errors at the `#` token in expression position. The make-green
follow-up adds the `hash_jni_env` lexer token, parser arm in
parsePrimary, AST node, and sema acceptance — body runs as a normal
block, env captured for later. TL push/pop semantics + optional env
in `#jni_call` land in 2.16b.
2026-05-20 10:32:56 +03:00
agra
dc3821aeb0 ffi 2.7 xfail: other six type-introducer directive forms parse
#jni_interface, #objc_class, #objc_protocol, #swift_class,
#swift_struct, #swift_protocol — each with the same body grammar as
#jni_class. Today the lexer doesn't recognise any of these directives
and the parser errors at the first one (`#jni_interface`). The
make-green follow-up adds the six lexer tokens and refactors
`JniClassDecl` into `ForeignClassDecl` with a `runtime` discriminator
so all seven forms share one AST shape and one parser path.
2026-05-20 10:11:50 +03:00
agra
11021d800d ffi 2.6 green: #jni_method_descriptor("(Sig)Ret") override
New `hash_jni_method_descriptor` lexer token + LSP keyword
classification. `JniMethodDecl` gains `desc_override: ?[]const u8`.
parseJniClassDecl accepts an optional `#jni_method_descriptor("...")`
clause between the return type and the terminating `;`, stashing the
literal as the override. Auto-derivation in Phase 2.8 will treat
this as the precedence override when present.

The 2.6 xfail commit (0ed4799) used the working name `#desc` in its
test file; this commit renames to `#jni_method_descriptor` for
parallel naming with the rest of the FFI directive set (`#jni_call`,
`#jni_class`, `#jni_env`, ...). Test snapshot flips xfail → green.

125/125 examples green.
2026-05-20 10:10:23 +03:00
agra
0ed4799f5f ffi 2.6 xfail: #desc("(Sig)Ret") per-method JNI descriptor override
`weirdMethod :: (self: *Self) -> s32 #desc("()I");` should parse,
today's 2.5 parser expects `;` immediately after the return type
and errors at the `#desc` token. The make-green follow-up adds a
`hash_desc` lexer token and threads an optional `desc_override`
field through `JniMethodDecl`.
2026-05-20 10:06:22 +03:00
agra
1dee9ba67b ffi 2.5 xfail: name: Type; field body item in #jni_class
`Point :: #jni_class("...") { x: s32; y: s32; }` should parse,
today's 2.4 body loop sees the identifier `x`, expects `::`, hits
`:` and errors. The make-green follow-up adds a `field` variant to
`JniClassMember` and a parser branch that detects `<ident>:` (vs
`<ident>::`) as the field-decl indicator.
2026-05-20 10:03:54 +03:00
agra
e225adbd1c ffi 2.4 xfail: #extends Alias; body item in #jni_class
`Window :: #jni_class("...") { #extends View; ... }` should parse,
today's 2.3 parser doesn't recognise `#extends` as a token and the
body loop reports "expected method name". The make-green follow-up
adds `hash_extends`/`hash_implements` lexer tokens, refactors
`JniClassDecl.methods` into a `members` tagged union, and dispatches
in the body loop on the leading token.
2026-05-20 09:36:20 +03:00
agra
082ef430a3 ffi 2.3 xfail: static method body item in #jni_class
`Math :: #jni_class("java/lang/Math") { static abs :: (n: s32) -> s32; }`
should parse, today's 2.2 parser treats `static` as a plain
identifier and errors at the following `abs`. The make-green
follow-up adds a `static` keyword recognition step in the body
loop and an `is_static` flag on `JniMethodDecl`.
2026-05-20 09:31:23 +03:00
agra
f5da453af1 ffi 2.2 xfail: instance method body item in #jni_class
`Foo :: #jni_class("path") { getId :: (self: *Self) -> s32; }`
should parse, today's 2.1 parser rejects any non-empty body. The
make-green follow-up extends parseJniClassDecl to loop over body
items collecting method declarations.
2026-05-20 09:28:07 +03:00
agra
4c670e66f3 ffi 2.0: xfail parser test for Foo :: #jni_class(...) { ... }
Today's parser doesn't recognize #jni_class as a hash directive
after `::`, so it falls through to expression parsing and errors
at the `#` token. Step 2.1 extends parseConstBinding to accept
the directive (opaque on empty body) and re-snapshots this file
to green.
2026-05-20 09:12:57 +03:00
agra
814eee1480 ffi 1.23: lock in undef shape for #jni_static_call
Test-add for static dispatch — `#jni_static_call(s32)(env, cls,
"max", "(II)I", 3, 7)` exercises GetStaticMethodID + CallStaticIntMethod
plus two integer args. Today the lowering bails on `is_static = true`
with `LLVMGetUndef`. IR snapshot captures the placeholder.

The next commit:
- Adds `Jni.GetStaticMethodID` (113), `Jni.CallStaticVoidMethod` (141),
  `Jni.CallStaticIntMethod` (129), etc. to the constants struct.
- Wires the static path: skip `GetObjectClass` (`target` IS the
  jclass), `NewGlobalRef(target)` to cache it, `GetStaticMethodID`
  for the method, then `CallStatic<Type>Method` per return type.
2026-05-19 22:40:47 +03:00
agra
908b6d19a3 ffi 1.22: lock in undef shape for #jni_call(*void)
Last return-type variant in the matrix. JNI's jobject is a pointer
(LocalRef) — sx's `*void` maps to LLVM `ptr` directly. CallObjectMethod
is at vtable slot 34. IR snapshot captures today's `ret ptr undef`.
Next commit adds the `.ptr => Jni.CallObjectMethod` arm.

LocalRef lifetime: the returned jobject is a JNI LocalRef bounded by
the native frame. Chains of calls within one frame consume LocalRefs
inline; calls that need to escape the frame should be promoted via
`NewGlobalRef` (already wired in the slot-interning path). Step 1.22
doesn't introduce automatic cleanup — chess use consumes objects
inline, matching the pattern in sx_android_jni.c.
2026-05-19 22:36:36 +03:00
agra
1ee4017426 ffi 1.21: lock in undef shape for #jni_call(bool)
Test-add for the jboolean return. JNI `jboolean` is a single byte (0
or 1); sx's `bool` lowers to LLVM `i1` with byte-coercion at the ABI
boundary. CallBooleanMethod is at vtable slot 37.

IR snapshot captures today's `ret i1 undef`. Next commit adds the
`.bool => Jni.CallBooleanMethod` arm.
2026-05-19 22:33:58 +03:00
agra
5e8145af93 ffi 1.20: lock in undef shape for #jni_call(f64)
Test-add for the jdouble return-type variant — `#jni_call(f64)(env,
target, "getValue", "()D")`. First non-integer return type for JNI.
IR snapshot captures today's `ret double undef` placeholder. The
next commit adds the `.f64 => Jni.CallDoubleMethod` arm.
2026-05-19 22:31:58 +03:00
agra
da5b6351e2 ffi 1.19: lock in undef shape for #jni_call(s64)
Test-add for the jlong return-type variant — same shape as 1.18's
jint test but exercising `#jni_call(s64)(env, target,
"currentTimeMillis", "()J")`. Today the non-void switch falls
through to `LLVMGetUndef`; the IR snapshot captures the placeholder.

The next commit adds the `.s64 => Jni.CallLongMethod` arm. The
snapshot will update to show the full dispatch through vtable slot
52, reusing the 1.17 slot interning machinery.
2026-05-19 22:30:05 +03:00
agra
1d7ea72dc8 ffi 1.18: lock in undef shape for #jni_call(s32)
Adds `examples/ffi-jni-call-04-jint-return.sx` exercising
`#jni_call(s32)(env, target, "getCount", "()I")` inside a runtime-
reachable but never-invoked helper (`g_should_call` stays false, so
the dereferences don't fire). Today the emit_llvm switch falls
through to `LLVMGetUndef` for any non-void return — the IR snapshot
captures that placeholder.

The next commit adds the `.s32 => 49` (CallIntMethod) arm. The
snapshot will update to show the full GetObjectClass → GetMethodID →
CallIntMethod sequence (reusing the slot interning landed in 1.17,
since `("getCount", "()I")` is a fresh literal pair).
2026-05-19 22:26:03 +03:00
agra
13018ef3b4 ffi 1.16: lock in pre-caching #jni_call IR shape
Adds `examples/ffi-jni-call-03-methodid-sharing.sx` with two
`#jni_call` sites against the same (class, method, sig). Today each
site emits its own `GetObjectClass` + `GetMethodID` + `Call<Type>Method`
sequence (8 vtable indirections total for the two-call test); 1.17
will collapse the two `GetMethodID` calls into a single cached
`jmethodID` static slot populated at module init, mirroring the
`OBJC_SELECTOR_REFERENCES_*` shape that 1.5 introduced for `#objc_call`.

Runtime is a no-op — `unused_jni` is reachable through a
runtime-readable `g_should_call` global that stays false, so the JNI
dereferences never execute. A plain `if false` would get
constant-folded, taking the function definition out of the IR
entirely; the global keeps both the function and its body present
for the IR-snapshot harness.

IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`
locks the pre-caching shape. The next commit (1.17) updates it to the
collapsed shape.

113/113 host tests pass.
2026-05-19 21:41:26 +03:00
agra
134c197dd4 ffi 1.15: xfail — Android cross-compile of #jni_call(void)
Adds `examples/ffi-jni-call-02-void.sx` exercising `#jni_call(void)
(env, target, "name", "sig")` inside an `inline if OS == .android`
arm, plus a new tuple in `tests/cross_compile.sh`. Host run_examples
passes (the inline-if strips the JNI body, leaving "skipped"); the
Android cross-compile FAILs because `lowerFfiIntrinsicCall` still
emits the placeholder diagnostic for any `fic.kind != .objc_call`.

Per the FFI cadence rule this is a test-add (xfail); the next
commit makes the Android cross-compile green by adding the
`.jni_msg_send` opcode and its emit_llvm expansion.
2026-05-19 21:25:42 +03:00
agra
ac78490dd7 ffi 1.32 backfill: locked-in test for #objc_call(CGRect) + #objc_call(u64)
Closes the runtime-verification gap from cluster 1.32. The migrated
`uikit_keyboard_will_change_frame` body uses both shapes but isn't
reached by chess startup (the soft keyboard doesn't open without user
input), so runtime verification was transitive only: `#objc_call(CGRect)`
via the structurally-identical `#objc_call(UIEdgeInsets)` (4×f64 HFA)
in ffi-objc-call-07, and `#objc_call(u64)` via the LLVM-equivalent
`#objc_call(s64)` `hash` test in ffi-objc-call-04.

This example installs two IMPs via `class_addMethod`:
- `rect_imp` returns a CGRect of {10.5, 20.5, 30.5, 40.5} through the
  32-byte HFA path (v0..v3 on AAPCS64).
- `u64_imp` returns `0x7FEDCBA987654321` through the i64 path.

`#objc_call(CGRect)` and `#objc_call(u64)` dispatch through them and
the values are printed for snapshot lockdown.

Reused the parser quirk noted in the checkpoint and in 0.1 — integer
literals ≥ 2^63 are rejected even when the receiving type is u64, so
the test value keeps the high bit clear.

111/111 host tests pass.
2026-05-19 21:19:09 +03:00
agra
df2ccf77bd issue-0038 fixed: closure capture through FfiIntrinsicCall args
`collectCaptures` in `src/ir/lower.zig` was the closure free-variable
analyzer that decides which names from a closure body need to be
boxed into the env struct at lambda-build time. Its switch on AST
node kind enumerated every other shape (`.call`, `.if_expr`,
`.match_expr`, `.for_expr`, etc.) but no arm for `.ffi_intrinsic_call`,
so the trailing `else => {}` quietly dropped its `args[]` and
`return_type` walks. Names referenced inside `#objc_call(T)(recv,
"sel:", ...)` from a closure body never made it into the captures
list, so when lowering bound the closure scope from env, those names
came back as "unresolved".

The fix adds the missing arm — walk `return_type` and every `args[i]`
the same way `.call` walks `callee` + `args`.

Companion changes:
- `examples/issue-0038.sx` → `examples/103-ffi-closure-capture.sx`
  (out of the open-issue namespace; comment header tightened to
  describe the feature, not the historical bug).
- `examples/ffi-objc-call-09-in-construct.sx` drops the
  `g_hasher_recv` module-global workaround that was added for this
  bug — the closure now captures `recv` from `make_hasher`'s arg
  list normally.
2026-05-19 21:14:31 +03:00
agra
35359b88f8 issue-0038: xfail repro — recv capture inside #objc_call
Uncomments the second passthrough case in `examples/issue-0038.sx`
that captures `recv` from the enclosing function into a closure body
that uses it inside `#objc_call(s64)(recv, "hash")`. Current behavior
is a hard error from the name-resolution pass:

    examples/issue-0038.sx:28:48: error: unresolved: 'recv'

Snapshot locks the failure in (exit 1 + that error message) so the
next commit can flip it to passing without ambiguity. Per the FFI
cadence rule this is a test-add (xfail); the make-green follow-up
adds the missing recursion arm in `lower.zig`'s `collectCaptures` for
`.ffi_intrinsic_call` nodes.
2026-05-19 21:10:58 +03:00
agra
e52f9f275e ffi 1.28 backfill: locked-in test for #objc_call(bool)
Closes the runtime-verification gap from cluster 1.28: chess startup
doesn't reach the keyboard `becomeFirstResponder` / `resignFirstResponder`
path, so `#objc_call(bool)` was only compile-verified. This example
installs two BOOL-returning IMPs via `class_addMethod` (type encoding
"B@:") and dispatches both through `#objc_call(bool)`. Also exercises
the nil-receiver guarantee (libobjc returns a zero slot, which decodes
as false).

This is a test-add commit (per the FFI cadence rule): it locks in
current behavior without changing any lowering. Lowering shape is
identical to `#objc_call(u8)` at the ABI layer; this test makes the
source-level type explicit and gives `git bisect` a target if a
future emit_llvm change inadvertently breaks single-byte returns.

110/110 host tests pass.
2026-05-19 20:12:09 +03:00
agra
0bb7b8cc27 issue-0037 fixed: ptr↔int conversion in coerceToType / bitcast emit
109/109 regression tests pass; chess Android + iOS-sim still
build clean.

Root cause: sx's `xx <ptr>` cast targeting an integer type
(common pattern: `xx u64 = xx @some_global`) lowered to a no-op
because `coerceToType` had branches for int↔float and same-kind
widen/narrow, but nothing for pointer↔integer. The cast left the
value as a pointer Ref, and `emitInst`'s `.ret` arm tried to
coerce a `ptr` value to an `i64` slot — coerceArg had no
ptr↔int branch either, fell through to undef.

Why it worked in main but failed in helpers: an
`alloca u64`+`store ptr @g, alloca`+`load i64, alloca` sequence
preserves the address bits as raw memory, so the
"store-then-load through an alloca" workaround happened to do
the right thing without a real cast. A `ret i64 <ptr>` has no
such intermediate slot and triggers an LLVM type mismatch.

Fix layered into two existing IR opcodes:

  lower.zig (coerceToType):
    new branch — when src and dst types are ptr↔int, emit a
    `bitcast` IR opcode with the right from/to. Mirrors how
    int↔float emits `.int_to_float` / `.float_to_int`.

  emit_llvm.zig (.bitcast arm):
    dispatch ptr→int to `LLVMBuildPtrToInt` (+ trunc/zext if the
    target int width != 64), int→ptr to `LLVMBuildIntToPtr`. The
    "real bitcast" path stays for same-kind type punning.
    Modern LLVM's BuildBitCast rejects ptr↔int directly, hence
    the dispatch.

The fix also closes a quiet behavior gap that affected non-`#foreign`
globals (any `xx @<global>` from a helper fn). Surfaced while
investigating issue-0037; verified independently with a
non-`#foreign` sx-side global of type `s64`.

File mechanics: issue-0037 promoted to a focused feature example
per CLAUDE.md's resolution flow:
  examples/issue-0037.sx        -> examples/102-foreign-global-from-helper.sx
  tests/expected/issue-0037.{txt,exit} -> tests/expected/102-foreign-global-from-helper.{txt,exit}

ffi-objc-call-03 + ffi-objc-call-06 IR snapshots updated to
reflect the ptr→int store-via-ptrtoint shape that's now correct
at the LLVM-IR level (same bits in memory, but properly typed).
2026-05-19 19:18:31 +03:00
agra
5fad92785e ffi 1.14: #objc_call OS-gating cross-compiles cleanly to Android
109/109 host tests pass; tests/cross_compile.sh's first real tuple
(`android | examples/ffi-objc-call-10-os-gate.sx`) compiles
through `sx build --target android` without finding any
`@objc_msgSend` / `@sel_registerName` symbols in the output —
the `inline if OS == .ios { #objc_call(...) }` arm is stripped
at sx compile time before emit_llvm runs, so the Android
toolchain (Bionic + libGLESv3 / NDK linker) doesn't see the
Obj-C runtime references that would otherwise be undefined.

Host (macOS): the example prints "host stripped both" — the iOS
arm is stripped (we're not iOS) AND the Android arm is stripped
(we're not Android), confirming `inline if OS == { case }`
symmetric strip-and-render works around `#objc_call` sites.

The example carries a 3-line `android_main` trampoline so the
NDK linker's `-u ANativeActivity_onCreate` / entry-point
discovery is satisfied — pattern shared with chess + the other
android examples.
2026-05-19 19:00:47 +03:00
agra
6dab8a157f ffi 1.11–1.13: #objc_call inside struct method / protocol / closure / generic
108/108 regression tests pass (+ffi-objc-call-09-in-construct,
+issue-0038 from the prior commit).

One trivial Obj-C call (`[obj hash]` returning NSUInteger) routed
through four sx surface constructs:

  1. struct method body          Probe.fetch
  2. protocol impl method body   impl Hashable for Probe
  3. closure value body          make_hasher
  4. generic function body       hash_through(recv: $T)

No new ABI shapes touched — pins that the `objc_msg_send` lowering
emits identical call shapes regardless of enclosing scope. Each
case validates the result `h_N == h_1` after threading `recv`
appropriately for each context.

The closure path reaches `recv` via a module-level global rather
than capturing the surrounding parameter — issue-0038 (prior
commit) documents the closure free-variable analyzer missing the
`FfiIntrinsicCall` node, with a clean workaround pinned.
2026-05-19 18:57:41 +03:00
agra
39b1bd03a6 issue-0038: closure free-var analysis skips FfiIntrinsicCall nodes
Surfaced while writing the Phase 1.11 in-construct test. The
closure free-variable analyzer doesn't recursively visit the
`ffi_intrinsic_call` AST node introduced in Phase 1.1, so any
identifier used inside `#objc_call` / `#jni_call` /
`#jni_static_call` from a closure body trips:

  error: unresolved: '<name>'

The same identifier captured from the same scope into a plain
expression resolves fine — so the bug is localized to whatever
recursive arm-walk powers the capture analysis.

Likely fix: add an `ffi_intrinsic_call => { ... }` arm wherever
the `.call =>` arm visits `callee` + `args`. Candidate files:
  - src/sema.zig (capture / scope tracking)
  - src/ir/lower.zig (closure body lowering / `lowerLambda`)
Both should be checked.

Workaround in the meantime: reach the captured value via a
module-level global from inside the closure body. See the
`g_hasher_recv` pattern in
examples/ffi-objc-call-09-in-construct.sx for an applied
instance.
2026-05-19 18:57:26 +03:00
agra
f4b6cdae18 ffi 1.10: multi-keyword Obj-C selectors through #objc_call
106/106 regression tests pass (+ffi-objc-call-08-multi-keyword).

`#objc_call(s32)(instance, "combine:and:", 7, 42)` round-trips
end-to-end via class_addMethod-registered IMP that does
`a * 100 + b` → 742. Pins three things:

1. The two-keyword selector "combine:and:" parses, mangles, and
   interns under the symbol `@OBJC_SELECTOR_REFERENCES_combine_and_`
   (every `:` → `_` — matches clang).
2. Multi-arg call lowering correctly puts arg0 / arg1 in the right
   slots after recv / sel.
3. The IMP-side sx fn signature `(self, _cmd, a: s32, b: s32)`
   with `callconv(.c)` interops with the Obj-C runtime's typical
   IMP shape, and the runtime forwards the keyword args to the
   right physical positions.

No codegen change — Phase 1.6's variadic-args branch in the
`objc_msg_send` lowering already handled this; this test just
locks in the surface.
2026-05-19 18:53:19 +03:00
agra
794a49e938 ffi 1.9: 4×f64 HFA round-trip through #objc_call (UIEdgeInsets shape)
105/105 regression tests pass (+ffi-objc-call-07-fp-hfa-return).

Same round-trip pattern as 1.8 — register an Obj-C class at
runtime with class_addMethod, IMP returns specific non-zero values,
#objc_call reads them back — but for an all-double 32 B HFA
instead of a 24 B int aggregate.

Locks in the f32-vs-f64 landmine that bit us when we first
wrote safeAreaInsets in uikit.sx: the homogeneous-float-aggregate
ABI routes 1..4 f32 or f64 fields through v0..v3 (AAPCS64) /
xmm0..xmm3 (SysV AMD64) WITHOUT integer coercion. As long as the
LLVM call-site function type carries the precise struct (which
our `objc_msg_send` arm does), the backend lowers it correctly.

This is the smaller cousin of 1.8 — 1.8 needed an emit_llvm code
change to make the sret transform work; 1.9 needs no codegen
change because HFAs of any size up to v0..v3 stay register-resident.
The test just pins that path with a real, value-bearing IMP so a
future ABI-rule shake-up has a regression net.
2026-05-19 18:51:56 +03:00
agra
e388687f1a ffi 1.8b: sret transform for #objc_call(>16 B non-HFA struct)
104/104 regression tests pass. The Triple round-trip
(triple_imp writes {11, 22, 33} on the IMP side → #objc_call(Triple)
reads them back) is the test of record.

emit_llvm.zig changes:

1. `objc_msg_send` arm — when `needsByval(ret_ty)` (same predicate
   the plain-foreign-call path uses), apply the sret transform:
     - ret type collapses to void
     - prepend a `ptr` param at index 0 (call site provides an
       alloca slot)
     - mirror `sret(<RetType>)` on the call site so the AArch64 x8
       / SysV-AMD64 hidden-ptr ABI lowers correctly
     - load the result from the slot post-call
   The IR shape now matches clang exactly:
     call void @objc_msgSend(ptr sret({...}) %slot, ptr %recv, ptr %sel)

2. `.ret` arm — the body-side counterpart for sx fns whose declared
   return type is sret-shaped (sx-defined IMPs registered via
   `class_addMethod` produce these). When the current function's
   `needsByval(func.ret)` predicate holds, store the IR ret value
   through the prepended sret slot (param 0) and emit `ret void`.
   Previously the unconditional coerceArg path turned the struct
   value into `undef` and emitted `ret void undef` — illegal LLVM.

Test mechanics: registers `SxTripleProbe : NSObject` at runtime via
`objc_allocateClassPair` + `class_addMethod`, IMP returns
Triple{11, 22, 33}. `#objc_call(Triple)(instance, "tripleValue")`
gets them back, round-trip pinned in the .txt snapshot and the
IR-shape snapshot.
2026-05-19 18:50:26 +03:00
agra
865890aed9 ffi 1.8a: xfail — #objc_call(>16 B non-HFA) skips the sret transform
103/103 regression tests pass (+ffi-objc-call-06-sret-return).

The runtime output is misleadingly clean — `[nil tripleValue]`
zeros all three fields because libobjc's nil-stub clears the
return registers. But the IR snapshot reveals the actual ABI
mismatch:

  %objc.msg = call { i64, i64, i64 } @objc_msgSend(ptr null, ptr %load)

A live receiver returning a non-zero `Triple` would surface
garbage in the third field — the AArch64 backend lowers
{ i64, i64, i64 } returns to x0/x1 pair + a third register that
the runtime's sret-shaped stub doesn't populate.

Next commit (1.8b): emit_llvm's `objc_msg_send` arm gains the
same sret transform we did for plain `#foreign` calls in Phase
0.3 — ret type collapses to void, prepend a ptr sret param,
alloca the result slot at the call site, mirror the
`sret(<T>)` attribute on the call, load result from the slot
post-call. IR snapshot will flip to:

  %slot = alloca <Triple>
  call void @objc_msgSend(ptr sret(<Triple>) %slot, ptr null, ptr %load)
  %objc.msg = load <Triple>, ptr %slot
2026-05-19 18:45:57 +03:00
agra
af79a15422 ffi 1.7: small struct returns through #objc_call (≤16 B + HFAs)
103/103 regression tests pass (+ffi-objc-call-05-struct-returns).
Three return shapes all round-trip cleanly with the existing Phase
1.6 `objc_msg_send` lowering — no codegen change needed because
emit_llvm.zig hands the IR struct type straight to LLVMBuildCall2
and the AArch64 / SysV AMD64 backends already know how to lower:

  NSPoint  — 16 B HFA (2×f64) → v0, v1 (AAPCS64) / xmm0, xmm1 (SysV)
  NSRange  — 16 B 2×u64       → x0, x1 register pair via [2 x i64]
  NSRect   — 32 B HFA (4×f64) → v0..v3 (AAPCS64) / xmm0..xmm3 (SysV)

Verified against the Obj-C runtime's `[nil structMethod]`-returns-
zero contract — no real-object setup needed, but the wider ABI
path runs exactly as it would for live receivers (the registers
the runtime stub uses come back through the same lowering).

>16 B non-HFA aggregates (e.g. {3×s64}) trip a sret cliff and
land in Phase 1.8. Verified locally that they return garbage in
the trailing field today — register pair / quad won't carry the
extra storage, and emit_llvm's `objc_msg_send` arm doesn't apply
the sret transform yet.
2026-05-19 18:44:14 +03:00
agra
d43385112c ffi 1.6: objc_msg_send IR opcode + per-call-site LLVM fn type
102/102 regression tests pass; chess Android + iOS-sim still build
clean. `ffi-objc-call-04-primitive-returns` flips from xfail to
passing with both nil-recv and real-recv flavors of *void / s64
returns exercised.

Key change: a new `objc_msg_send` IR opcode bundles (recv, sel,
extra args) and carries the return type via the `Inst.ty` field.
emit_llvm.zig builds a per-call-site LLVM function type from the
argument Refs' IR types (recv/sel as ptr; extra args through
abiCoerceParamType) and dispatches with LLVMBuildCall2. One
declared `@objc_msgSend` symbol is reused across every return
type — opaque pointers make the function value type-erased, so
each call site picks its own ABI.

  before:  one (recv, sel) -> ptr LLVM declaration, hard-coded
           per call site; only void return wired in 1.3.
  after:   same declaration, each call site provides a fresh
           LLVMBuildCall2 fn-type → s64 / *void / bool / f64
           returns all dispatch correctly without separate FuncIds.

Selector init mechanism: stayed with the @llvm.global_ctors
constructor. Investigated clang's
`__DATA,__objc_selrefs` + `externally_initialized` shape — works
for fully-linked binaries (dyld substitutes the SEL at load
time) but **LLVM ORC JIT** (the engine behind `sx run`) doesn't
process Mach-O Obj-C metadata sections, so the slot keeps its
initial value (the method-name string pointer) and dispatch
crashes with "<null selector>". The portable choice: keep the
constructor AND inject a direct call to it at `main`'s entry —
idempotent under dyld (sel_registerName returns the same SEL on
re-registration), required for ORC JIT.

Files touched:
  src/ir/inst.zig    | new ObjcMsgSend struct + opcode
  src/ir/lower.zig   | drop the void-only restriction; emit the
                       new opcode; remove the orphaned
                       getObjcMsgSendFid path (objc_msgSend
                       declaration moved to emit_llvm)
  src/ir/emit_llvm.zig | objc_msg_send arm (per-call-site
                       LLVMBuildCall2); lazy `@objc_msgSend`
                       declaration via getObjcMsgSendValue;
                       emitObjcSelectorInit refactored to inject
                       the ctor call at main's entry
  src/ir/{print,interp}.zig | switch arms for the new opcode

`ffi-objc-call-03-selector-sharing.ir` snapshot updates to
reflect the new shape (the `call ... @objc_msgSend` call sites
no longer mention a typed wrapper).
2026-05-19 18:39:10 +03:00
agra
baeab179c3 ffi 1.6a: xfail — #objc_call with non-void return rejected today
102/102 regression tests pass (+ffi-objc-call-04-primitive-returns
with xfail snapshot capturing today's diagnostic).

Pinned scenario: `[NSObject class]` — `#objc_call(*void)(null, "class")`.
Should return a non-null Class pointer once the lowering supports
non-void returns. Today the Phase 1.3 restriction trips with:

  #objc_call: only `void` return + (recv, selector) is lowered today;
  non-void / arg-bearing arities land in later phase-1 steps

The next commit (1.6b) introduces an `objc_msg_send` IR opcode that
bundles (recv, sel, args, ret_ty) and emit_llvm builds a per-call-
site LLVM function type, sharing one declared `@objc_msgSend`
symbol across return-type variants. Five primitive returns
(*void / bool / s32 / s64 / f64) get folded in across 1.6b–c.
2026-05-19 18:02:43 +03:00
agra
26a04e49d0 tests: IR-snapshot harness — diff sx ir output when .ir present
run_examples.sh now supports an optional `tests/expected/<name>.ir`
sibling to `.txt`/`.exit`. When present, the runner also captures
`sx ir <file>` output, normalizes target-/host-specific noise
(module ID, target triple/datalayout, attribute groups, LLVM's
auto-suffixed %temp numbering), and diffs against the snapshot.
`--update` regenerates it alongside the runtime output.

Catches lowering changes that don't affect what the program prints
— exactly the shape Phase 1.5's selector interning will produce
(same runtime output, very different IR).

First snapshot: `ffi-objc-call-03-selector-sharing.ir`. Today the
test emits four `call ptr @sel_registerName(ptr @str.N)` lines for
its four call sites; after 1.5 we expect two static
`@OBJC_SELECTOR_REFERENCES_<sel>` globals + loads at each call
site. The diff between the two snapshots will be the visible
artifact of the optimization.
2026-05-19 13:01:28 +03:00
agra
c54ca755fa ffi 1.4: regression test for shared-selector #objc_call sites
101/101 regression tests pass (+ffi-objc-call-03-selector-sharing).

Test exercises four call sites — three sharing "init" and one
"release" — to pin the multi-site / multi-selector lowering before
1.5 changes how SEL lookups are cached.

Runtime behavior: identical before and after 1.5 (all call sites
hit nil receivers; libobjc returns 0 for void). The improvement is
visible only in the emitted IR — today:

  $ ./zig-out/bin/sx ir examples/ffi-objc-call-03-selector-sharing.sx \\
      | grep -c "call ptr @sel_registerName"
  4

After 1.5 (planned): 2 — one `sel_registerName` per unique selector
string, materialized into a static `OBJC_SELECTOR_REFERENCES_<sel>`
global at module init, then loaded at each call site. Matches the
shape clang produces for `@selector(...)`. Worth re-running the
above grep after 1.5 lands as a manual sanity check.

The IR-shape snapshot harness (auto-diff of `sx ir` output) is
deferred; for now we verify by eye.
2026-05-19 12:59:13 +03:00
agra
d1e9def0c6 ffi 1.3: xfail end-to-end void-return #objc_call (no codegen yet)
100/100 regression tests pass (+ffi-objc-call-02-void-return xfail
snapshot).

The intrinsic with no `inline if false` guard reaches sema/codegen
and trips an "unresolved: 'unknown_expr'" — the FfiIntrinsicCall
AST node from Phase 1.1 has no lowering rules in lower.zig /
emit_llvm.zig yet.

nil receiver was chosen so the test doesn't need a real Obj-C
object graph: the runtime guarantees `[nil msg]` is a no-op with
zero result for void returns. macOS-gated via `inline if OS == .macos`
so the runner stays portable.

Next commit: emit_llvm.zig produces the per-call-site
  %sel = call ptr @sel_registerName(ptr "init.0")
  call void @objc_msgSend(ptr null, ptr %sel)
lowering. Snapshot flips to "ok". Selector interning (one shared
global per unique selector string) lands as a separate step (1.5).
2026-05-19 12:48:38 +03:00
agra
83480b3a66 ffi 1.2: parser coverage for #jni_call and #jni_static_call
99/99 regression tests pass (+ffi-jni-call-01-parse).

Locks in the same parse-surface contract for the JNI intrinsics
that ffi-objc-call-01-parse pins for the Obj-C side:

  #jni_call(*void)(null, null, "getWindow", "()Landroid/view/Window;");
  #jni_static_call(s32)(null, null, "max", "(II)I", 3, 7);
  #jni_call(bool)(null, null, "isShown", "()Z");

All three lower through the shared `FfiIntrinsicCall` AST node
added in 1.1; only the kind tag distinguishes them. `inline if false`
keeps sema/codegen out of the picture until later phase-1 steps
wire those in.
2026-05-19 12:46:53 +03:00
agra
fca7e9ce2a ffi 1.0: xfail parser test for #objc_call(T)(recv, "sel:", args...)
98/98 regression tests pass (+ffi-objc-call-01-parse with xfail
snapshot capturing today's parse error).

Phase 1 of PLAN-FFI.md introduces three compiler intrinsics
(`#objc_call`, `#jni_call`, `#jni_static_call`) that lift the
ceremony off the existing typed-`objc_msgSend` and JNI dispatch
patterns. This is the first step of the cadence:

  1.0 (this commit): test-add. Locks the current parse rejection.
  1.1 (next):        make-green. Parser accepts the new syntax;
                     this snapshot updates to whatever the next
                     pipeline stage produces (sema/codegen still
                     can't lower the intrinsic — that's later
                     phase-1 steps).
  1.3+:              codegen lands; the test eventually runs
                     cleanly against Foundation.

`inline if false` wraps the call site so the AST carries the node
but no codegen runs for it. Lets Phase 1.1's parse-only test pass
without dragging in the sema/codegen plumbing prematurely.
2026-05-19 12:40:21 +03:00
agra
831b46ac35 ffi 0.10: extend 94-foreign-global with cross-file companion
97/97 regression tests pass (94 expected updated; +issue-0037 from
the prior commit).

The companion `94-foreign-global-helper.sx` ALSO declares
`__stdinp : *void #foreign;`. Two sx files referencing the same
extern symbol must link cleanly — LLVM dedupes the named global at
the module level, and the C linker resolves both refs to the one
libSystem definition.

The full ergonomic story (helper computes the *same* address as the
main file's direct read) is blocked on issue-0037: lower.zig's
`address_of(global)` branch produces `undef` when the body is a
non-main function, even single-file. Once issue-0037 closes, fold
the helper's address back into an equality check here.

The cross-file link itself works today and is the lemma we're
locking in. This is also the closest thing today to the cross-file
extern-global ergonomic issue-0030 wants — `#foreign` already works
across files; the missing piece is sx-side `extern` decls for
sx-defined globals.
2026-05-19 12:06:08 +03:00
agra
161254f5bb issue-0037: @foreign_global from a helper function lowers to undef
Repro found while writing PLAN-FFI step 0.10.

In a single file:

  __stdinp : *void #foreign;
  stdinp_addr :: () -> u64 { xx @__stdinp; }
  main :: () -> s32 {
      a : u64 = xx @__stdinp;     // a = real symbol address
      b := stdinp_addr();         // b = 0
      ...
  }

The emitted IR for the helper is `ret i64 undef`, suggesting the
`address_of(identifier=__stdinp)` branch in lower.zig (~line 1719)
doesn't see `__stdinp` in `global_names` at the moment the helper's
body is being lowered — even though the same lookup succeeds inside
main's body in the same compilation unit.

Likely cause: lazy-body lowering ordering vs. the pass that
registers extern global decls into `global_names`. Worth verifying
which before fixing — could also be per-function scoping of the
map. Phase 1 of the FFI plan doesn't depend on this, so it stays
filed as an open issue and gets addressed when convenient (or when
sx-side `extern` cross-file globals from issue-0030 land and need
the same lookup to work everywhere).
2026-05-19 12:05:55 +03:00
agra
efc482a055 ffi 0.8: switch form for the inline-if OS branch (no behavior change)
`inline if OS == { case .macos: ... case .ios: ... else: ... }` is
already supported (see library/modules/platform/sdl3.sx:42 and
examples/38-build-config.sx:30). Cleaner than the chained
`inline if OS == .a;  inline if OS == .b;  ...` form the prior
commit used.

Same expected output — only the macOS arm survives codegen on the
host. Snapshot unchanged.
2026-05-19 12:01:01 +03:00
agra
608ff34d55 ffi 0.9: foreign-result chains — handle threaded through struct + List
96/96 regression tests pass (+ffi-09-foreign-result-chain).

Opaque C-handle pattern that mirrors how real sx code threads
MTLBuffer*, AAssetManager*, file pointers, etc. through composite
sx values. C side has a trivial heap-int handle (`ffi_chain_make`
returning `void*`, `ffi_chain_bump` / `_peek` / `_dispose`). The sx
side exercises:

  1. Chained calls   — make -> bump -> bump -> peek; one handle
                       threaded through four FFI sites in sequence.
  2. Struct field    — `Counter { handle: *void; label: string; }`
                       hosts the handle; methods/accesses go through
                       `.handle` to feed back into C.
  3. List(*void)     — push N handles, iterate, peek each, iterate
                       again to bump each, iterate again to read
                       back. Catches any aliasing / lifetime breakage
                       when handles round-trip through the slice
                       backing of List.
2026-05-19 11:59:18 +03:00
agra
d0ccb92ef7 ffi 0.8: #foreign call sites inside struct/protocol/closure/inline-if
95/95 regression tests pass (+ffi-08-foreign-in-method).

One trivial C helper (`ffi_method_helper`) called from each of the
major sx surface constructs that can host an FFI site:

  1. struct method body                Counter.next
  2. protocol impl method body         impl Doubler for Counter
  3. closure value body                make_adder's `closure(...)`
  4. comptime-gated branch             `inline if OS == .macos { ... }`

No new ABI shapes — the lowering route a `#foreign` call site takes
shouldn't depend on its enclosing construct, and the test pins that
lemma. A future lowering refactor that, say, breaks protocol-dispatch
fast-paths for FFI-calling impl methods will fail here directly
instead of being caught only by the chess Android regression.

The `inline if` branches for ios/linux compile down to nothing on
macOS, so only the macOS arm fires at runtime — useful smoke test
that the comptime gate works around FFI sites too.
2026-05-19 11:57:44 +03:00
agra
3855f2351e ffi: move test-companion .c/.h next to their .sx (drop vendors/ namespace)
vendors/ is a third-party namespace (stb_image, kb_text_shape, etc.);
test fixtures don't belong there. The .c/.h companion files for the
Phase-0 FFI baselines now sit alongside the .sx that drives them in
examples/, with matching basenames:

  examples/ffi-01-primitives.{sx,c,h}    <- was vendors/ffi_primitives/
  examples/ffi-02-small-struct.{sx,c,h}  <- was vendors/ffi_structs/
  examples/ffi-03-large-struct.{sx,c,h}  <- was vendors/ffi_large_struct/
  examples/ffi-04-fp-struct.{sx,c,h}     <- was vendors/ffi_fp_struct/
  examples/ffi-05-string-args.{sx,c,h}   <- was vendors/ffi_strings/
  examples/ffi-06-callback.{sx,c,h}      <- was vendors/ffi_callback/
  examples/101-ffi-medium-struct.{sx,c}  <- was vendors/ffi_medium_struct/

`#source` / `#include` paths in the .sx files become bare filenames
(no prefix) since imports.zig's base_dir resolution finds them
relative to the importing .sx file's directory.

`library/vendors/sx_ffi_resolve_test/` stays put — that one's the
whole point: regression coverage for the stdlib-search branch of
the resolution chain, so it must live where ONLY that branch can
find it.

94/94 regression tests pass.
2026-05-19 11:54:36 +03:00
agra
c08a749043 ffi 0.7: #import c { #include / #source } via stdlib-path resolution
94/94 regression tests pass (+ffi-07-c-import-block).

Companion C helper lives only at
`library/vendors/sx_ffi_resolve_test/`. Critically NOT in
`sx/vendors/` (the sx repo root) and NOT in the importing
example's directory — so the `vendors/...` paths in this
example are findable solely via the stdlib search branch
(`<exe>/../../library`, `<exe>/../library`, `<exe>/library`).

That branch is the one the JNI insets bridge needs to reach
`library/vendors/sx_android_jni/sx_android_jni.c` without
forcing chess (or any consumer) to vendor an identically-named
copy. The test pins the resolution end-to-end:
  - #include  resolves; clang parses the .h; c_import.zig
    synthesizes #foreign fn decls for `sx_ffi_resolve_test_add` /
    `_mul`.
  - #source   resolves; the .c is compiled into the build's
    object list.
  - sx calls the synthesized decls and prints results.
2026-05-19 11:51:34 +03:00
agra
31ab175d56 ffi 0.6: C-to-sx callback baseline (1-arg + ctx-ptr forms)
93/93 regression tests pass (+ffi-06-callback).

Mirrors the `app->onInputEvent` install pattern from
library/modules/platform/android.sx:

  1. (s32) -> s32              — single primitive arg/return
  2. (*void, s32) -> s32       — opaque ctx pointer + value
                                  (the onInputEvent shape)

Side effects via two file-level globals so the test observes both
the return value AND state mutation across multiple calls:
- g_callback_hits = N proves the callback fired N times.
- g_callback_sum  = sum of args proves each individual call landed
  with the correct value.

The ctx-pointer variant casts `*void` back to `*s32` inside the
callback and reads through it (`p.*`), proving the pointer survives
the round-trip with no aliasing weirdness.
2026-05-19 11:48:34 +03:00
agra
31715bd251 ffi 0.5: string + byte-pointer baseline through #foreign
92/92 regression tests pass (+ffi-05-string-args).

Covers the four shapes that actually appear at the sx ↔ C boundary
today:

  1. [:0]u8 string literal -> const char*  (ffi_strlen, ffi_first_byte)
  2. sx `string` value via .ptr            (slice-decay branch in
                                            coerceArg pulls the pointer)
  3. [*]u8 raw buffer + length             (ffi_sum_bytes, mutated via
                                            ffi_write_byte and read back)
  4. C-returned const char*                (round-trips back as [*]u8)

The mutate-via-C path catches any pointer-aliasing regression — sx
allocates the fixed array `bytes : [4]u8`, passes `.ptr` to C which
writes index 1, and the sx side reads `bytes[1]` to confirm the
mutation took effect through the same memory.
2026-05-19 11:46:47 +03:00
agra
736382d39c ffi 0.4: focused FP-aggregate (HFA) baseline — FQuad + DQuad
91/91 regression tests pass (+ffi-04-fp-struct).

Single-file regression net for the all-float / all-double aggregate
ABI path:

  FQuad — 16 B, 4×f32   (same slot as ffi-02's Vec4f)
  DQuad — 32 B, 4×f64   (UIEdgeInsets-shape — the f32-vs-f64 landmine)

Already nominally covered by ffi-02's Vec4f, but pinning it as a
focused single-file test means a future ABI rule change that breaks
the HFA path fails *this* test directly without a noisy drag-in from
the multi-shape baseline.

DQuad at 32 B straddles the AAPCS64 HFA limit (≤4 floats of same
type, total ≤64 B); it stays as a struct value passed through
v0..v3 rather than going indirect. The snapshot confirms the values
arrive intact.
2026-05-19 11:44:43 +03:00
agra
2463eea1d4 ffi 0.3: large struct baseline (Big24, Big48) through sret return path
90/90 regression tests pass (+ffi-03-large-struct).

vendors/ffi_large_struct/{.h,.c} defines:
  Big24 — 24 B, three s64    (byval params + sret return)
  Big48 — 48 B, six s64      (same path, larger)

`make / rotate-or-reverse / sum` helpers per shape. The sx-side
example imports via `#source` only and declares matching structs +
hand-written #foreign decls.

Snapshot pins today's >16-byte aggregate ABI now that the
emit_llvm.zig sret-return transform is in place (previous commit).
That gives us a regression net for all four C-ABI aggregate slots
in one place:

  ≤8 B int       — i64 coercion          (ffi-01 vec-likes)
  9..16 B int    — [2 x i64] coercion    (ffi-02 Pair64/Quad32, 101)
  16 B HFA       — struct, no coercion    (ffi-02 Vec2/Vec4f)
  >16 B          — byval params + sret    (this commit)
2026-05-19 11:41:06 +03:00
agra
edd8689fb2 ffi 0.2: fold Pair64 + Quad32 back into small-struct baseline
Now that emit_llvm.zig bridges the struct<->[2 x i64] ABI mismatch
(previous commit), the 9..16-byte integer-only shapes round-trip
cleanly. Extended `examples/ffi-02-small-struct.sx` to cover all
four aggregate ABI slots in one place:

  Vec2   — 8 B,  two f32    (register pair, float)
  Vec4f  — 16 B, four f32   (HFA — homogeneous float aggregate)
  Pair64 — 16 B, two s64    (9..16 B int — [2 x i64] coercion slot)
  Quad32 — 16 B, four s32   (same slot as Pair64)

Vendor helpers (`vendors/ffi_structs/{ffi_structs.h,ffi_structs.c}`)
grow `ffi_pair64_*` + `ffi_quad32_*` companions. Snapshot updated
to capture the full output. 89/89 regression tests pass.

`examples/101-ffi-medium-struct.sx` keeps a minimal focused repro
of the Pair64 case so the issue's emergence-and-fix history stays
greppable.
2026-05-19 11:32:36 +03:00
agra
7d2c2fb062 emit_llvm: bridge struct<->array ABI for 9..16-byte foreign structs
Resolves issue-0036 (LLVM verifier failure on 16-byte integer-only
struct by value through #foreign). The mismatch:

  Call parameter type does not match function signature!
    %load = load { i64, i64 }, ptr %alloca, align 8
  [2 x i64]  %call = call [2 x i64] @fn({ i64, i64 } %load)

`abiCoerceParamType` had already chosen `[2 x i64]` for 9..16-byte
non-HFA structs (the AAPCS64 / SysV AMD64 register-pair ABI slot for
that size class) on the foreign-decl side, but `coerceArg` only knew
how to bridge struct<->integer (the ≤8 B case) — not struct<->array.
LLVM's verifier rejects type-mismatched call args, so the call site
never landed.

Added the symmetric branches in coerceArg:
  - Struct -> Array : alloca <array>; store <struct>; load <array>
  - Array -> Struct : alloca <array>; store <array>;  load <struct>

Both use the LLVM opaque-pointer memory-bitcast pattern already in
place for the integer case. They're paired with the existing
i64 <-> small-struct bridge so all four (≤8 B int, 9..16 B int,
16 B HFA, >16 B byval) ABI slots round-trip cleanly through
emit_llvm now.

File mechanics: promotes the issue-0036 repro to a focused feature
example per CLAUDE.md's issue-resolution workflow:

  examples/issue-0036.sx              -> examples/101-ffi-medium-struct.sx
  tests/expected/issue-0036.{txt,exit} -> tests/expected/101-ffi-medium-struct.{txt,exit}
  vendors/issue_0036/issue_0036.c     -> vendors/ffi_medium_struct/ffi_medium_struct.c

Snapshot updated to the passing output. 89/89 regression tests pass;
chess Android build still clean.
2026-05-19 11:31:04 +03:00