Commit Graph

123 Commits

Author SHA1 Message Date
agra
5cc62e63c3 bundling: fs/process stdlib + post-link callback + Apple .app in sx
Campaign Weeks 3-6 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
land in one push: the bundling pipeline that used to live in
src/target.zig (createBundle, embedFramework, extractEntitlements,
buildInfoPlist, codesign) now lives in
library/modules/platform/bundle.sx and runs in the IR interpreter
after target.link() returns.

New language-side surface:
- library/modules/fs.sx — POSIX libc bindings (open/read/write/close,
  mkdir/unlink/rmdir, chmod, rename, access, basename/dirname). Variadic
  open() lowers to C's varargs via the new args: ..T form. Direct libc
  calls bypass *File method dispatch so they work from the post-link
  IR interpreter.
- library/modules/process.sx — popen-based run(cmd) returning
  ProcessResult{ exit_code, stdout }, plus env() and find_executable().
- library/modules/std.sx — xml_escape(s) and variadic path_join(parts).
- library/modules/compiler.sx — BuildOptions grows
  set_post_link_callback / set_post_link_module / binary_path
  accessors; bundle_path/bundle_id/codesign_identity/provisioning_profile
  setters + accessors; per-target predicates is_macos/is_ios/
  is_ios_device/is_ios_simulator + target_triple; framework_count /
  framework_at(i) / framework_path_count / framework_path_at(i);
  add_asset_dir(src, dest) + asset_dir_count / src_at / dest_at.

Compiler-side wiring:
- src/ir/compiler_hooks.zig — BuildConfig now carries post_link_callback_fn,
  post_link_module, binary_path, bundle_*, target_triple,
  target_frameworks, target_framework_paths, asset_dirs. Hook registry
  exposes every accessor; getters return "" / 0 for unset fields so
  bundle.sx can treat absent values uniformly.
- src/ir/host_ffi.zig (new) — dlsym(RTLD_DEFAULT) + arity-switched cdecl
  trampolines so #foreign("c") declarations resolve through the host
  libc during #run / post-link interpretation.
- src/ir/interp.zig — callForeign dispatch; build_config pointer
  injection so accessor hooks see live state during re-entry.
- src/core.zig — keeps the IR module alive past generateCode; exposes
  invokeByName / invokeByFuncId so main.zig can re-enter the
  interpreter after linking.
- src/main.zig — wires bundle/codesign/provisioning CLI flags +
  target_triple + framework lists into BuildConfig; invokes the
  post-link callback (by FuncId or by <module>.bundle_main lookup) once
  target.link() returns. When --bundle is set but no callback is
  registered, auto-falls-back to post_link_module = "platform.bundle"
  so the legacy --bundle CLI keeps working for any program that imports
  modules/platform/bundle.sx.

Apple .app bundler (library/modules/platform/bundle.sx):
- Single bundle_main entry covers macOS, iOS simulator, iOS device.
  Per-target Info.plist switch keys off is_ios()/is_ios_simulator() —
  iOS emits UIDeviceFamily / LSRequiresIPhoneOS /
  UIApplicationSceneManifest / DTPlatformName (iPhoneOS or
  iPhoneSimulator); macOS emits the minimal CFBundle* set.
- iOS-only steps:
  - Provisioning embed: fs.read_file + fs.write_file to
    <bundle>/embedded.mobileprovision.
  - Framework embed: recursive cp -R per -F search path into
    <bundle>/Frameworks/<Name>.framework/ (until fs.sx grows list_dir).
  - Entitlements extraction: four process.run calls (security cms -D,
    plutil -extract Entitlements xml1, plutil -extract
    ApplicationIdentifierPrefix.0, plutil -replace application-identifier)
    resolving the wildcard <TEAM>.* -> <TEAM>.<bundle_id>.
  - Real codesign with --entitlements when present.
- Asset dirs (add_asset_dir): recursive cp -R src/. into <bundle>/dest/.
  Missing src is treated as "nothing to do" so projects can register
  add_asset_dir("assets", "assets") unconditionally.

Parser:
- parseStmt() now accepts #import \"path\"; and #framework \"Name\"; as
  statement-position tokens. Needed for top-level
  inline if OS == .android { #import \"modules/platform/android.sx\"; }
  blocks (issue-0042 flatten pass surfaces them); chess's
  inline-if-with-#import was rejected at parse time before this fix.

Removals from src/target.zig:
- createBundle, embedFramework, extractEntitlements, buildInfoPlist,
  codesign (~210 lines). main.zig no longer calls createBundle after
  link(); the sx callback is the single entry point.

Tests / regression markers (all run under sx run host JIT):
- examples/115-post-link-callback.sx — callback registration round-trip.
- examples/116-fs-roundtrip.sx — fs.write_file -> fs.read_file -> exists.
- examples/117-process-roundtrip.sx — process.run + env + find_executable.
- examples/118-macos-bundle.sx — macOS .app via bundle_main callback.
- examples/119-interp-cast-ptr-cmp.sx — cast(T) val under interpreter.
- examples/120-interp-variadic-any.sx — variadic ..Any indexing in IR
  interpreter.
- examples/121-ios-sim-bundle.sx — iOS-sim cross-compile + .app with
  iOS-shaped Info.plist (added to tests/cross_compile.sh as the
  ios-sim tuple).
- examples/122-ios-device-bundle.sx — iOS device cross-compile +
  full codesign pipeline (provisioning embed + entitlements
  extraction + --entitlements codesign). Manually verified end-to-end:
  installed via xcrun devicectl device install app + launched
  successfully on iPhone 17 Pro.
- examples/123-inline-if-import-in-body.sx — locks in the parser fix.

zig build && zig build test && bash tests/run_examples.sh => 141 passed,
0 failed; bash tests/cross_compile.sh => 7 passed, 0 failed.
2026-05-22 19:03:31 +03:00
agra
30fed66616 ffi #foreign: C-variadic tail via args: ..T
Trailing `args: ..T` on a #foreign declaration now lowers to the C
calling convention's `...` instead of sx-side slice-packing. Drops
the per-arity #foreign-shim workaround for callers of variadic C
APIs (__android_log_print, printf-family, etc.). Closes issue-0043.

- IR: Function.is_variadic on inst.Function; declareFunction drops
  the variadic param from the IR signature for foreign+variadic
  decls.
- emit_llvm: LLVMFunctionType receives is_var_arg=1 when the flag
  is set; call lowering passes extras through unchanged.
- Lowering: packVariadicCallArgs early-outs for foreign+variadic
  (no slice-pack); new promoteCVariadicArgs applies C default
  argument promotion (bool/s8/s16/u8/u16 -> s32, f32 -> f64) to
  extras past the fixed param count.
- Test: examples/ffi-foreign-cvariadic.sx + .c exercise s64/f64/s32
  returns through C va_arg over s32/f64/*u8 element types.

134 host + 6 cross tests pass on the WIP-less baseline.
2026-05-22 13:13:43 +03:00
agra
c02b6b3b1b ffi #jni_main: Alias.new(args) constructor dispatch via JNI NewObject
Adds the constructor-invocation arm of the foreign-class DSL:
`SurfaceView.new(ctx)` (where `SurfaceView` is a `#foreign #jni_class`
with `static new :: (ctx: *Context) -> *Self;`) lowers to
`FindClass(env, "android/view/SurfaceView") + GetMethodID(env, cls,
"<init>", "(args)V") + NewObject(env, cls, mid, args...)`. Returns
the fresh jobject.

  - inst.zig: `JniMsgSend.is_constructor` flag + `parent_class_path`
    re-purposed to carry the class being constructed (alongside its
    existing nonvirtual-super-class use). Mutually exclusive with
    `is_static` / `is_nonvirtual`.
  - lower.zig: `lowerCall.field_access` arm now recognises
    `Alias.method(args)` where `Alias` resolves in `foreign_class_map`
    and the matching member is `static`. `new` routes to a new
    `lowerForeignStaticCall` that derives a `(args)V` JNI descriptor
    and emits a `JniMsgSend` with `is_constructor=true`. Non-`new`
    static calls report a clear "use #jni_static_call" diagnostic
    until that sugar lands.
  - emit_llvm.zig: new `NewObject` vtable slot (28) + `emitJniConstructor`
    helper expanding the FindClass+GetMethodID+NewObject chain. The
    jni_msg_send arm short-circuits to it when `is_constructor` is set.

Smoke `ffi-jni-main-03-ctor.sx` exercises both this slice and the
previous super-dispatch slice in a single `onCreate` body: calls
`super.onCreate(b)` then constructs a `SurfaceView` with the Activity
as Context. IR shows the expected six-stage chain (FindClass+GetMethodID+
CallNonvirtual + FindClass+GetMethodID+NewObject); APK builds clean.

Naming caveat: the Java type `android.content.Context` clashes with
sx stdlib's `Context :: struct {...}` (heap-context). The smoke aliases
it `JContext` — future work could add a path-prefix or `as` rename
form on `#jni_class` to avoid the manual rename.

133 host / 6 cross / zig build test all green.
2026-05-20 17:14:51 +03:00
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
f10daa384a ffi 1.24: verify inverse OS gate for #jni_call on iOS-sim
Adds `ios-sim|examples/ffi-jni-call-02-void.sx` to the cross-compile
tuple list. The `inline if OS == .android { #jni_call(...) }` arm in
that example must strip its body before sema/lower runs on iOS,
otherwise emit_llvm would attempt to load libjvm vtable slots that
don't exist in the iOS SDK and the link step would fail.

This is the JNI mirror of step 1.14, which did the same for
`#objc_call` against Android. Phase 1C is functionally complete:
- Parser accepts all three FFI intrinsics (1.1–1.2)
- `#objc_call` full return-type matrix + selector interning (1.3–1.10)
- `#objc_call` enclosing-construct coverage (1.11–1.13)
- `#objc_call` cross-Android gate (1.14)
- `#jni_call(void)` codegen with vtable indirection (1.15)
- `#jni_call` literal-keyed slot interning (1.16–1.17)
- `#jni_call` return-type matrix s32/s64/f64/bool/*void (1.18–1.22)
- `#jni_static_call` lowering (1.23)
- `#jni_call` cross-iOS gate (1.24, this commit)

3/3 cross-compile tuples pass; 118/119 host tests pass (one
unrelated regression in working tree). Next: Phase 1D for
`library/vendors/sx_android_jni/sx_android_jni.c` — migrate the C
JNI helpers to sx via `#jni_call`. Requires on-device chess
verification per the FFI plan.
2026-05-19 22:44:41 +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