Commit Graph

117 Commits

Author SHA1 Message Date
agra
d946e3d577 ffi #jni_main: sx-side super.method(args) dispatch via CallNonvirtual<T>Method
Inside a `#jni_main` (or any sx-defined `#jni_class`) bodied method,
`super.method(args)` lowers to JNI's nonvirtual dispatch against the
parent class resolved via `#extends` (default `android.app.Activity`).

  - lower.zig: tracks `current_foreign_class` + `current_foreign_method`
    around each `synthesizeJniMainStub` body; pushes the JNIEnv* arg
    onto the lexical `#jni_env` stack so omitted-env JNI calls inside
    the body see env without a wrapper. New `lowerSuperCall` handles
    the `super.method(args)` receiver pattern: derives parent path,
    reuses the enclosing method's signature when names match (the
    common `super.<override>(args)` case), or looks up the method on
    the parent class declared as `#foreign #jni_class`.
  - inst.zig: `JniMsgSend` gains `is_nonvirtual: bool` and
    `parent_class_path: ?[]const u8` — the dispatch tag + super class
    foreign path. Mutually exclusive with `is_static`.
  - emit_llvm.zig: new `CallNonvirtual<T>Method` vtable slots + a
    fourth dispatch arm. Resolves the parent jclass via
    `FindClass(env, parent_path)` (per-call; caching is follow-up),
    then `GetMethodID(env, parent_cls, name, sig)`, then
    `CallNonvirtual<T>Method(env, obj, parent_cls, mid, args...)`.

Disassembly on the smoke confirms the chain:
`ldr [env+0x30]` (FindClass) → `ldr [env+0x108]` (GetMethodID) →
`ldr [env+0x2d8]` (CallNonvirtualVoidMethod) with `(env, self,
parent_cls, mid, bundle)`.

132 host / 5 cross / zig build test all green. The slice unblocks
Activity lifecycle overrides (onCreate, onResume, onPause) calling
their required `super.<method>(args)` without raw `#jni_call`
boilerplate.
2026-05-20 16:57:30 +03:00
agra
1ae43495c2 ffi #jni_main slice 2: AOT pipeline — .java + javac + d8 → classes.dex in APK
Compilation.lowering_jni_main_decls is populated by lowerToIR (iterating
foreign_class_map for is_main && !is_foreign && runtime==jni_class,
deduped by foreign_path); each entry carries the pre-rendered Java source
from jni_java_emit.emitJavaSource.

createApk extended: when the emission list is non-empty, write each
.java under <stage>/java/<pkg>/<Class>.java, javac --release 11 to
<stage>/classes/, d8 --release --lib <android_jar> --output <stage>
to produce <stage>/classes.dex, then zip the .dex into the unaligned
APK at root level. javac discovery: $JAVA_HOME/bin/javac first, then
`which javac`.

Manifest still hardcodes android.app.NativeActivity (slice 3 wires the
user's class name + android:hasCode="true"), so the bundled .dex is
present but unreferenced at runtime. End-to-end verified via dexdump on
the smoke example's APK — Lco/swipelab/sxjnimain/SxApp; extending
NativeActivity shows up in classes.dex. Non-#jni_main APK builds
(99-android-egl-clear.sx) produce the same shape as before.

Cross-compile tuple added for examples/ffi-jni-main-01-emit.sx
(compile-only — APK exercise is manual).
2026-05-20 14:42:03 +03:00
agra
6a3260ff65 ffi 2.16c green: TL fallback via C-helper runtime + always-omit env in #jni_call
`#jni_call` collapses to a single surface — env is *always* implicit:
either picked up from the lexically-enclosing `#jni_env(env) { ... }`
block's Ref (cheap, register-resident, no TL touch) or from the
runtime's thread-local slot via `sx_jni_env_tl_get()` (one fn call
per dispatch). The explicit-env shape is gone — chess and the
existing tests migrate cleanly by wrapping their helper-fn bodies
in `#jni_env(env) { ... }`.

The TL slot lives outside the user's IR module so the LLVM ORC JIT
can load object files cleanly without `orc_rt` for TLS support:

  library/vendors/sx_jni_runtime/sx_jni_env_tl.c:
    static _Thread_local void *sx_jni_env_tl_slot;
    void *sx_jni_env_tl_get(void) { return sx_jni_env_tl_slot; }
    void sx_jni_env_tl_set(void *env) { sx_jni_env_tl_slot = env; }

Linkage:
- sx-the-compiler links the .c file via build.zig so the JIT
  process-symbol generator resolves `sx_jni_env_tl_get`/`_set`.
- AOT targets get the same .c file auto-linked via the lowering
  pass: when lower touches the TL externs, it sets
  `needs_jni_env_tl_runtime`, and `Compilation.lowerToIR` appends a
  synthetic `CImportInfo` to `lowering_extra_c_sources` that
  `collectCImportSources` merges with user-written ones.

Lowering-side changes:
- `getJniEnvTlFids` lazily declares the two externs (parallel
  to `getSelRegisterNameFid`) and flips `needs_jni_env_tl_runtime`.
- `#jni_env(env) { body }` emits save→set→body→restore via three
  `call` ops to the externs; the inner body sees env via the
  lexical-direct stack.
- `lowerJniCall` resolves env from `jni_env_stack` (top) or the TL
  fallback. The explicit-env branch is gone.
- `jni_env_stack_base` tracks per-fn lexical scope so lazy-lowering
  a callee doesn't accidentally see the caller's Ref (Refs are only
  valid inside one fn's instruction stream).

Test migration (mechanical):
- ffi-jni-call-{01..09}: each helper fn wraps `#jni_call(...)`
  bodies in `#jni_env(env) { ... }`. Returning values pass through
  the block as an expression — `#jni_env` now also lowers in
  expression position.

Verified:
- zig build test + tests/run_examples.sh: 130/130 green.
- tests/cross_compile.sh: 3/3 green.
- Chess APK rebuilt + reinstalled on Pixel. Board renders with
  status-bar clearance + info panel intact; no crashes in logcat.
  Safe-insets dispatch through `#jni_env` + lexical-direct now
  fully exercised end-to-end on real hardware.
2026-05-20 13:53:25 +03:00
agra
013cf9f1bb ffi 2.16c xfail: omitted env in #jni_call from cross-function helper
A `#jni_call(void)(target, "name", "sig")` inside a helper fn that
isn't lexically inside a `#jni_env` block should fall back to a
thread-local env read populated by the enclosing `#jni_env(env) {
helper(target); }` scope at runtime. Today the lower-side
"jni_env_stack empty" diagnostic gets queued but compilation
continues to emit_llvm, which fails LLVM verification because env
lowers to `Ref.none` (`i64 undef`).

The make-green follow-up:
- Synthesizes a thread-local `@sx_jni_env_tl` global in emit_llvm.
- `#jni_env(env) { body }` emits a `(load TL → saved, store env → TL,
  defer store saved → TL)` sequence so the TL tracks the
  innermost-scope env and restores correctly on nesting.
- `lowerJniCall`'s omitted-env path falls back to a TL load when
  `jni_env_stack` is empty, instead of erroring.

The lexical-direct optimisation from 2.16b stays the fast path —
helpers in the same fn never touch TL. Only cross-fn callees pay
the (cheap) TL load.
2026-05-20 12:51:48 +03:00
agra
2882748ae6 ffi 2.11 green: DSL call sites on #jni_class-typed receivers lower to JNI dispatch
`inst.method(args)` on a value typed as a foreign-class alias
(`Activity :: #jni_class("android/app/Activity") { getWindow ::
(self: *Self) -> *Window; }` etc.) now lowers to `jni_msg_send`
with descriptor auto-derived from the sx signature, env from the
enclosing `#jni_env` scope (lexical-direct via 2.16b), and slot
interning re-used from Phase 1C.

Touch surface:
- `Lowering` gains `foreign_class_map: StringHashMap(*const
  ForeignClassDecl)` populated in `scanDecls` + `lowerDecls`.
- New `registerForeignClassDecl` records each declared alias; the
  type-bridge fallback already interns the alias as a 0-field
  struct, so `*Activity` resolves cleanly through `getStructTypeName`.
- New `lowerForeignMethodCall` looks up the method in
  `ForeignClassDecl.members`, derives the descriptor via
  `jni_descriptor.deriveMethod` (with a `ClassRegistry` built from
  `foreign_class_map`), and emits `jni_msg_send` directly. Filters
  by runtime — `jni_class`/`jni_interface` lower; `objc_class` etc.
  surface a clear "not yet supported" diagnostic until Phase 3/4.
- `lowerCall`'s method-dispatch arm inserts the foreign-class
  check before the standard struct-method resolution.

JNI descriptor derivation gains `*void → Ljava/lang/Object;` (the
opaque-jobject convention) — common when sx code doesn't have a
precise Java type for the value. Locked in with a unit test.

IR snapshot at `tests/expected/ffi-jni-class-08-call.ir` shows the
full lowering: env from the enclosing fn param, target from the
foreign-class arg, slot-interned `(class, method, sig)` cache
pair, jni_msg_send to `CallObjectMethod` (slot 34). Mangled slot
names `@SX_JNI_CLS_getWindow____Ljava_lang_Object_` confirm the
derived descriptor.

129/129 examples + 16 jni_descriptor unit tests green.
2026-05-20 11:07:41 +03:00
agra
09e4ec2aa5 ffi 2.11 xfail: DSL call site on #jni_class-typed receiver
`act.getWindow()` on `act: *Activity` (where `Activity ::
#jni_class("android/app/Activity") { getWindow :: ... }`) should
lower to `#jni_call(*void)(act, "getWindow", "()Ljava/lang/Object;")`
(omitted-env form picking up env from the enclosing `#jni_env`
scope via 2.16b's lexical-direct path). Today's sema reports
"unresolved: 'getWindow'" because foreign-class members aren't
yet wired into the method-resolution path.

The make-green follow-up needs:
  - sema: register `ForeignClassDecl.members` so method names
    resolve on foreign-class receivers (or suppress the unresolved
    fallback for them).
  - lower: build a `foreign_class_map` in scan pass; new arm in
    `lowerCall`'s method-dispatch site emits a synthetic
    `FfiIntrinsicCall { kind: jni_call, args: [target, "name",
    "(sig)Ret", method_args...] }` with the descriptor derived via
    `jni_descriptor.deriveMethod`.
  - type system: `*Activity` resolution path so `inferExprType`
    on the receiver returns a known type (likely register foreign
    classes as synthetic 0-field structs reusing the struct-type
    machinery).

Larger session needed — pausing here at the xfail.
2026-05-20 10:59:39 +03:00
agra
022ca31050 ffi 2.16b green: lexical-direct env in #jni_call inside #jni_env
`Lowering` gains a `jni_env_stack: ArrayList(Ref)`. When lowering
the `jni_env_block` arm pushes the env_expr's Ref before lowering
the body and pops after; `defer` ensures cleanup on early return.

`lowerJniCall` now disambiguates explicit-vs-omitted env via the
position of the first string-literal arg: at index 1 → omitted
(3-arg form `target, "name", "sig"`), at index 2 → explicit
(4-arg form `env, target, "name", "sig"`). Omitted form reads the
top of `jni_env_stack`; missing scope → diagnostic.

End-to-end test runs cleanly. Locked-in IR snapshot at
`tests/expected/ffi-jni-env-02-lexical-direct.ir` shows env coming
from the enclosing fn's `*void` param straight into the jni_msg_send
expansion — no extra load, no thread-local read. The hot-path
optimisation from the design discussion is now real.

128/128 examples + 1 new IR snapshot green; zig test clean.
2026-05-20 10:54:37 +03:00
agra
e463385404 ffi 2.16b xfail: omitted env in #jni_call inside #jni_env
`#jni_call(void)(target, "name", "sig")` (3 args before the first
string literal) should work inside an enclosing `#jni_env(env) { ... }`
scope, picking up the env from the block's value directly. Today's
lowering expects 4+ args and errors with "#jni_call requires env,
target, method name, and signature".

The make-green follow-up adds a lowering-side env stack maintained
across the `#jni_env` body walk, and a disambiguation in
`lowerJniCall` that detects "env omitted" via the position of the
first string-literal arg (method name at index 1 → omitted; at index
2 → explicit env).
2026-05-20 10:47:07 +03:00
agra
5bd2c84bb6 ffi 2.16a green: parser + AST + sema for #jni_env(env) { body }
New `hash_jni_env` lexer token; `parsePrimary` dispatches to a small
`parseJniEnvBlock` that consumes `(env) { body }` and returns a new
`JniEnvBlock` AST node (env_expr + body block).

Sema's analyzeNode arm recurses into env + body inside a pushed
scope; findNodeAtOffset descends through both children for go-to-
definition.

Lowering treats it as a syntactic wrapper around the block: env is
evaluated for side effects, body lowers as a normal block. The TL
push/pop semantics (synthesizing the env stack so `#jni_call`'s env
arg can become optional) land in 2.16b.

`expectSemicolonAfter` recognises `jni_env_block` as block-form so
statement-position uses don't need a trailing `;` — matches `if` /
`while` / `for` / bare blocks.

Test runs through the block body and prints expected output; xfail
snapshot flips to green. 127/127 examples green.
2026-05-20 10:41:24 +03:00
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
5fd8e0fbbe ffi 2.7 green: parser accepts all seven type-introducer directive forms
Six new lexer tokens (`hash_jni_interface`, `hash_objc_class`,
`hash_objc_protocol`, `hash_swift_class`, `hash_swift_struct`,
`hash_swift_protocol`) join the existing `hash_jni_class`. All seven
share the body grammar from Phases 2.1–2.6.

AST refactored: `JniClassDecl` → `ForeignClassDecl` with a
`runtime: ForeignRuntime` enum discriminator; `JniMethodDecl` →
`ForeignMethodDecl` (with `jni_descriptor_override` renamed for
clarity since it's JNI-only); `JniFieldDecl` → `ForeignFieldDecl`;
`JniClassMember` → `ForeignClassMember`. AST variant renamed
`jni_class_decl` → `foreign_class_decl`.

`parseForeignClassDecl` takes the runtime as a parameter; the
`parseConstBinding` dispatch table now maps each of the seven
directive tokens to its `ForeignRuntime` variant via
`foreignRuntimeForCurrent`. No codegen yet — Phase 3 picks up Obj-C
runtime, Phase 4 picks up Swift. Runtime-specific body items (fields,
descriptor override) are validated at sema time in later steps.

126/126 examples green.
2026-05-20 10:15:10 +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
a703eeec2a ffi 2.5 green: name: Type; field body items in #jni_class
New `JniFieldDecl` AST struct (name + field_type); `JniClassMember`
gains a `field` variant. After consuming a member-name identifier
in the body loop, the parser branches on the next token: `:` →
field path (parse type expr + `;`), `::` → method path (existing).

`static` fields aren't part of the grammar yet and error explicitly
("static fields not yet supported"); only instance fields land here.
Lowering to JNI `Get<Type>Field` / `Set<Type>Field` arrives in 2.13.

124/124 examples green.
2026-05-20 10:05:30 +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
a5c6f754a8 ffi 2.4 green: #extends and #implements body items
Two new lexer tokens `hash_extends` / `hash_implements` (global tokens,
context-meaningful inside #jni_class bodies — same pattern as #using).
`JniClassDecl.methods` refactored into `members: []const JniClassMember`,
a tagged union with `method` / `extends` / `implements` variants.
Body loop dispatches on the leading token: `#extends Alias;` /
`#implements Alias;` consume the alias name and push a non-method
member; everything else falls through to the existing method path.

The alias on the right of `#extends` is the sx-side name (resolved
to the corresponding #jni_class at sema time in a later step), not
the foreign Java path — the path lives only in the alias's own
directive arg.

123/123 examples green.
2026-05-20 10:02:56 +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
ecce8cdca3 ffi 2.3 green: static body items in #jni_class
`JniMethodDecl` gains `is_static: bool = false`. parseJniClassDecl's
body loop now recognises a `static` identifier prefix (context-sensitive
— `static` stays a plain identifier elsewhere) and consumes it before
the method name, setting `is_static` on the resulting decl. Dispatch
to `GetStaticMethodID` / `CallStatic*Method` arrives in Phase 2.12.

122/122 examples green.
2026-05-20 09:35:09 +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
a2a2e83af0 ffi 2.2 green: parser collects #jni_class instance method body items
New `JniMethodDecl` AST struct (name, params, param_names,
return_type — no body, foreign declaration). `JniClassDecl.body`
becomes `methods: []const JniMethodDecl`. parseJniClassDecl loops
over body items, parsing each `name :: (self: *Self, args...) -> Ret;`
similarly to parseProtocolDecl but requiring `;` (no body brace).

`static`, fields, `#extends`, `#implements`, and the other six
directive forms land in 2.3–2.7. Sema/lower still treat the decl
as an opaque type alias — descriptor derivation arrives in 2.8+.

121/121 examples green.
2026-05-20 09:30:02 +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
32b464e959 ffi 2.1: parser accepts Foo :: #jni_class("path") { } opaque form
New `hash_jni_class` token + lexer entry, `JniClassDecl` AST node
(alias + java path; body deferred to 2.2+), `parseJniClassDecl`
consuming `("...") { }` and rejecting non-empty bodies for now.
Sema registers the alias as a type_alias symbol; LSP classifies
the directive as a keyword. The 2.0 xfail snapshot flips to
`parse-only ok`, exit 0.

120/120 examples green; zig test clean.
2026-05-20 09:24:14 +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
7b566bfb83 ffi 1.23: #jni_static_call lowering — make-green
Static dispatch wired in. The early `is_static` bail in
`.jni_msg_send` is gone; both paths now share the same lazy-cache +
phi structure with two static-specific differences:

1. `GetObjectClass` is skipped — for static calls, `target` IS the
   `jclass`. The cached `cls` slot just stores `NewGlobalRef(target)`
   directly.
2. The method-ID lookup uses `GetStaticMethodID` (slot 113), and the
   dispatch uses `CallStatic<Type>Method` (Object 114 / Boolean 117
   / Int 129 / Long 132 / Float 135 / Double 138 / Void 141).

Slot interning still applies: the `@SX_JNI_{CLS,MID}_<key>` pair is
shared between instance and static literal call sites with the same
`(name, sig)` — though in practice the JNI runtime treats instance
and static method-IDs as distinct, so two sites with the same name
but different dispatch kinds would collide in the cache. This isn't
a problem the chess Android backend hits (each method is uniquely
either static or instance in the API), so the simpler single-key
intern stays.

IR snapshot updated: `ret i32 undef` replaced by the full
NewGlobalRef → GetStaticMethodID → CallStaticIntMethod sequence
through vtable slots 21, 113, 129. Args `i32 3, i32 7` thread through
the existing arg-coercion loop.
2026-05-19 22:43:20 +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
b5694cc42d ffi 1.22: #jni_call(*void) → CallObjectMethod (slot 34) — make-green
Closes the return-type matrix. Pointer-return types aren't a simple
`TypeId` enum case (they're user-defined types interned into the
table), so the dispatch checks `TypeInfo.pointer | .many_pointer`
ahead of the primitive switch:

  const is_pointer_ret = switch (types.get(ret_ty_id)) {
      .pointer, .many_pointer => true,
      else => false,
  };
  const offset = if (is_pointer_ret)
      Jni.CallObjectMethod
  else switch (ret_ty_id) { .void => ..., .s32 => ..., ... };

LocalRef cleanup deferred: returned jobjects are JNI LocalRefs
bounded by the native frame. Chains of calls within one frame
consume them inline; cross-frame use must promote via `NewGlobalRef`
(already wired in the slot-interning path from 1.17). The chess
Android backend will consume objects inline, matching the manual
pattern in `sx_android_jni.c`.

Return-type matrix done: void, s32, s64, f64, bool, *void all
dispatch through their respective vtable slots. Static dispatch
(1.23) is next.
2026-05-19 22:38:09 +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
b0e8659c2f ffi 1.21: #jni_call(bool) → CallBooleanMethod (slot 37) — make-green
One-line addition: `.bool => Jni.CallBooleanMethod`. The lazy-cache
+ dispatch from 1.17 handles the rest. JNI's `jboolean` is i8 in the
C ABI but always carries 0 or 1; LLVM's call boundary truncates the
return byte to i1 and the sx-level bool reads the low bit
canonically.

IR snapshot updated: `ret i1 undef` replaced by the full sequence
through vtable slot 37 keyed on `("isShown", "()Z")`.
2026-05-19 22:35:20 +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
ca4ba7589c ffi 1.20: #jni_call(f64) → CallDoubleMethod (slot 58) — make-green
One-line addition to the switch: `.f64 => Jni.CallDoubleMethod`.
First non-integer JNI return type; same lazy-cache + dispatch
infrastructure from 1.17 handles the rest.

IR snapshot updated: `ret double undef` replaced by the full
sequence through vtable slot 58 keyed on `("getValue", "()D")`.
2026-05-19 22:32:40 +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
5945a8c176 ffi 1.19: #jni_call(s64) → CallLongMethod (slot 52) — make-green
One-line addition to the `call_method_offset` switch: `.s64 =>
Jni.CallLongMethod`. The 1.17 caching infrastructure and the named-
constants struct from c1877fc handle the rest.

IR snapshot at `tests/expected/ffi-jni-call-05-jlong-return.ir`
updated: `ret i64 undef` replaced by the full lazy-cache +
CallLongMethod (vtable slot 52) sequence keyed on
`("currentTimeMillis", "()J")`.
2026-05-19 22:30:49 +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
ebcfe4c4dc ffi 1.18: #jni_call(s32) → CallIntMethod (slot 49) — make-green
One-line addition to the `call_method_offset` switch in
`emit_llvm.zig` — `.s32 => 49` (CallIntMethod). The 1.17 caching
infrastructure handles the rest: GetObjectClass → NewGlobalRef →
GetMethodID populate the shared `@SX_JNI_{CLS,MID}_<key>` pair on
miss; per-call lowering loads the cached jmethodID and dispatches
through vtable slot 49 with an `i32` return.

IR snapshot at `tests/expected/ffi-jni-call-04-jint-return.ir`
updated: the `ret i32 undef` placeholder is replaced by the full
lazy-cache + CallIntMethod sequence keyed on
`("getCount", "()I")`. Pre-1.18 snapshot was 1d7ea72.
2026-05-19 22:26:58 +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
0d883b412d ffi 1.17: #jni_call(name, sig) literal-keyed slot interning
Two `#jni_call` sites with the same string-literal `(name, sig)` pair
now share a single `jclass` GlobalRef slot and a single `jmethodID`
slot, populated lazily on the first call to any matching site.
Non-literal sites keep the per-call `GetObjectClass` + `GetMethodID`
sequence from step 1.15.

Per-call-site lowering for literal sites:

  %cached_mid = load ptr, @SX_JNI_MID_<key>
  %is_cached  = icmp ne ptr %cached_mid, null
  br i1 %is_cached, cont, miss
miss:
  %local_cls  = GetObjectClass(env, target)
  %global_cls = NewGlobalRef(env, local_cls)     ; vtable slot 21
  store ptr %global_cls, @SX_JNI_CLS_<key>
  %fresh_mid  = GetMethodID(env, global_cls, name, sig)
  store ptr %fresh_mid, @SX_JNI_MID_<key>
  br cont
cont:
  %mid = phi ptr [%cached_mid, before], [%fresh_mid, miss]
  call <Type>Method(env, target, %mid, args...)

Wiring:
- `JniMsgSend.cache_key: ?CacheKey` (new) carries `(name_str,
  sig_str)` when both `name` and `sig` are string-literal AST nodes;
  empty for non-literal call sites.
- `lower.zig` populates `cache_key` from the AST.
- `emit_llvm.zig` `getOrCreateJniSlots(name, sig)` returns the
  `{cls_slot, mid_slot}` pair, creating and caching them on first
  lookup. Key is `name\x00sig` so the separator can't collide with
  any JNI identifier byte.
- `mangleJniKey` builds an LLVM-identifier suffix from the pair, used
  in the `@SX_JNI_{CLS,MID}_<suffix>` global names.

IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`
updated: two call sites against literal `("noop", "()V")` now share
`@SX_JNI_CLS_noop____V` and `@SX_JNI_MID_noop____V`. Pre-1.17 snapshot
had two independent `GetMethodID` calls; post-1.17 has one global
slot pair plus per-call lazy-init branches.

Note: an unrelated regression in `examples/ffi-objc-call-12-rect-u64-returns.sx`
exists in the working tree (parse error from an in-progress C-import
block) and is left untouched.
2026-05-19 22:22:55 +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