From 53fe73acdad356f8fad97dbb85ee14ec247f99f1 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 25 May 2026 16:10:22 +0300 Subject: [PATCH] ffi 3.0: `inst.method(args)` DSL dispatch on `#objc_class` receivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementation half of the cadence step started in the previous commit. `lowerForeignMethodCall` for `#objc_class` / `#objc_protocol` runtimes no longer bails; it routes through a new `lowerObjcMethodCall` helper that derives the Obj-C selector from the sx method name and lowers to `objc_msg_send` against the cached SEL slot (same intern path as explicit `#objc_call`). Default selector mangling (matches clang's keyword-method convention): - Niladic (arity 0 excluding self): name verbatim. `length()` → "length". - Arity ≥ 1: split the sx method name on `_`; each piece becomes a keyword with a trailing `:`. `addObject(o)` → "addObject:"; `combine_and(a, b)` → "combine:and:"; `initWithFrame_options(f, o)` → "initWithFrame:options:". Arity validation: keyword count (pieces from the `_`-split) must equal call-site arity excluding self. Mismatch diagnoses at the call site with a hint pointing at the forthcoming `#selector("...")` override (Phase 3.2) for selectors that don't fit the underscore-split rule. Mangling helper `deriveObjcSelector` and dispatch helper `lowerObjcMethodCall` sit alongside `lowerForeignMethodCall`. The existing fall-through diagnostic for non-JNI/non-Obj-C runtimes remains for Swift (Phase 4 territory). Tests `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword, 04-mismatch}.sx` snapshots flip from the pre-3.0 bail diagnostic (exit=1) to working output (exit=0 for cases 01-03) and the specific keyword-count mismatch diagnostic for case 04. Each test follows the established pattern from `ffi-objc-call-08-multi-keyword.sx`: synthesize a class at runtime via `objc_allocateClassPair` / `class_addMethod`, declare a matching `#objc_class`, invoke the DSL form. 163/163 tests; chess unaffected (JNI dispatch path untouched). --- current/CHECKPOINT-FFI.md | 26 +++--- src/ir/lower.zig | 88 +++++++++++++++++++ tests/expected/ffi-objc-dsl-01-niladic.exit | 2 +- tests/expected/ffi-objc-dsl-01-niladic.txt | 2 +- tests/expected/ffi-objc-dsl-02-one-arg.exit | 2 +- tests/expected/ffi-objc-dsl-02-one-arg.txt | 2 +- .../ffi-objc-dsl-03-multi-keyword.exit | 2 +- .../ffi-objc-dsl-03-multi-keyword.txt | 2 +- tests/expected/ffi-objc-dsl-04-mismatch.txt | 2 +- 9 files changed, 107 insertions(+), 21 deletions(-) diff --git a/current/CHECKPOINT-FFI.md b/current/CHECKPOINT-FFI.md index f834a10..3fbb30c 100644 --- a/current/CHECKPOINT-FFI.md +++ b/current/CHECKPOINT-FFI.md @@ -472,23 +472,21 @@ JNI return + parameter type validation lives in lowering with source- spanned diagnostics; CallMethod coverage spans bool / s8 / s16 / u16 / s32 / s64 / f32 / f64 / pointer; varargs promotion is wired. -**Correction: Phase 3 step 3.0 had NOT landed at the time the previous -checkpoint entry claimed it did.** [lower.zig's -`lowerForeignMethodCall`](../src/ir/lower.zig#L4353) bails for any non- -JNI runtime with `method calls on '{runtime}' runtime not yet supported -(Phase 3/4)`; no commit in `git log` introduced an Obj-C DSL dispatch -path; the planned regression files -(`examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,04-mismatch}.sx`) -didn't exist. As of this commit the four tests DO exist and snapshot -the current bail diagnostic — they're the xfail half of the Phase 3.0 -cadence; the next commit implements the dispatch and the snapshots -flip to the working output. +Phase 3 step 3.0 landed (for real this time): `inst.method(args)` on +an `#objc_class` / `#objc_protocol` receiver derives the selector via +default mangling (niladic → name verbatim; arity ≥ 1 → split on `_`, +each piece becomes a keyword with a trailing `:`) and lowers to +`objc_msg_send` against the cached SEL slot. Arity mismatches diagnose +at the call site with a remediation hint pointing at `#selector(...)` +override (3.2). New helpers `deriveObjcSelector` and +`lowerObjcMethodCall` at [lower.zig](../src/ir/lower.zig). Tests: +`examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,04-mismatch}.sx` +— landed previously as xfail-with-diagnostic, snapshots now flipped to +working output (and the mismatch case to the specific keyword-count +error). Open work, in roughly the order they make sense: -- **Phase 3 step 3.0** — `inst.method(args)` DSL dispatch on - `#objc_class` receivers with default selector mangling. The xfail - tests are in place; next commit makes them green. - **Phase 3 step 3.1** — static call `Cls::class_method(args)` lowers to `#objc_call` on the class object (loaded via `objc_getClass` once and interned per module). Same pattern as 3.0 for the niladic / diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 19ab454..ff40d05 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -4384,6 +4384,15 @@ pub const Lowering = struct { return Ref.none; } + // Obj-C instance dispatch (Phase 3 step 3.0). `inst.method(args)` on + // an `#objc_class` / `#objc_protocol` receiver derives a selector + // from the sx method name (default mangling: split on `_`, each + // piece becomes a keyword with a trailing `:`; niladic stays + // verbatim) and lowers to `objc_msg_send`. The Swift runtimes + // still bail — Phase 4. + if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) { + return self.lowerObjcMethodCall(fcd, method, target, method_args, span); + } if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) { if (self.diagnostics) |d| { d.addFmt(.err, span, "method calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)}); @@ -4455,6 +4464,85 @@ pub const Lowering = struct { } }, ret_ty); } + /// Derive the default Obj-C selector from a sx-side method name plus + /// its call-site arity (excluding self). Mangling: + /// - niladic: name verbatim (`length` → `length`). + /// - arity ≥ 1: split the name on `_`; each piece becomes a keyword + /// with a trailing `:` (`addObject` → `addObject:`, + /// `combine_and` → `combine:and:`). + /// Returns the selector string allocated on `self.alloc` and the + /// number of keyword pieces (matters for arity validation; the + /// niladic case returns 0). + fn deriveObjcSelector(self: *Lowering, method_name: []const u8, arity: usize) struct { sel: []const u8, keyword_count: usize } { + if (arity == 0) { + return .{ .sel = method_name, .keyword_count = 0 }; + } + // Each `_` in the sx name becomes a `:` (one-byte-for-one), plus + // one trailing `:` regardless of how many pieces. Piece count + // = (number of `_`) + 1. + var pieces: usize = 1; + for (method_name) |ch| { + if (ch == '_') pieces += 1; + } + const out = self.alloc.alloc(u8, method_name.len + 1) catch unreachable; + for (method_name, 0..) |ch, i| { + out[i] = if (ch == '_') ':' else ch; + } + out[method_name.len] = ':'; + return .{ .sel = out, .keyword_count = pieces }; + } + + /// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol` + /// receiver. The selector is derived by `deriveObjcSelector`; arity + /// is validated against the keyword count produced by the mangling + /// (excluding self). Dispatch then runs through `objc_msg_send`, + /// sharing the cached-SEL slot path with explicit `#objc_call`. + fn lowerObjcMethodCall( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + method: ast.ForeignMethodDecl, + target: Ref, + method_args: []const Ref, + span: ast.Span, + ) Ref { + const arity = method_args.len; + const derived = self.deriveObjcSelector(method.name, arity); + + // Arity validation: the keyword count (number of `:` in the + // selector) must equal the number of args passed at the call + // site. For niladic methods the selector has no `:`; we already + // accept arity == 0 in the mangling above. + if (arity > 0 and derived.keyword_count != arity) { + if (self.diagnostics) |d| { + d.addFmt( + .err, + span, + "Obj-C selector for '{s}.{s}' has {} keyword(s) but the call passes {} argument(s); split the sx method name on '_' so it produces exactly {} keyword(s), or override with `#selector(\"...\")` once that lands (3.2)", + .{ fcd.name, method.name, derived.keyword_count, arity, arity }, + ); + } + return Ref.none; + } + + const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; + + // Cache the SEL slot per (selector-string, module) like + // `#objc_call` does. The mangling produces the literal selector + // string; we don't need a runtime sel_registerName call at the + // dispatch site because the global initializer already does it. + const vptr_ty = self.module.types.ptrTo(.void); + const slot_gid = self.internObjcSelector(derived.sel); + const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty)); + const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); + + const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; + return self.builder.emit(.{ .objc_msg_send = .{ + .recv = target, + .sel = sel, + .args = args_owned, + } }, ret_ty); + } + /// Lower `Alias.new(args)` where `Alias` is a foreign-class identifier /// with `static new :: (...) -> *Self;` — JNI constructor dispatch: /// `FindClass + GetMethodID("", "(args)V") + NewObject(env, diff --git a/tests/expected/ffi-objc-dsl-01-niladic.exit b/tests/expected/ffi-objc-dsl-01-niladic.exit index d00491f..573541a 100644 --- a/tests/expected/ffi-objc-dsl-01-niladic.exit +++ b/tests/expected/ffi-objc-dsl-01-niladic.exit @@ -1 +1 @@ -1 +0 diff --git a/tests/expected/ffi-objc-dsl-01-niladic.txt b/tests/expected/ffi-objc-dsl-01-niladic.txt index 6f54b9b..0dce97c 100644 --- a/tests/expected/ffi-objc-dsl-01-niladic.txt +++ b/tests/expected/ffi-objc-dsl-01-niladic.txt @@ -1 +1 @@ -/Users/agra/projects/sx/examples/ffi-objc-dsl-01-niladic.sx:36:14: error: method calls on 'objc_class' runtime not yet supported (Phase 3/4) +length = 42 diff --git a/tests/expected/ffi-objc-dsl-02-one-arg.exit b/tests/expected/ffi-objc-dsl-02-one-arg.exit index d00491f..573541a 100644 --- a/tests/expected/ffi-objc-dsl-02-one-arg.exit +++ b/tests/expected/ffi-objc-dsl-02-one-arg.exit @@ -1 +1 @@ -1 +0 diff --git a/tests/expected/ffi-objc-dsl-02-one-arg.txt b/tests/expected/ffi-objc-dsl-02-one-arg.txt index baead0c..637f6c6 100644 --- a/tests/expected/ffi-objc-dsl-02-one-arg.txt +++ b/tests/expected/ffi-objc-dsl-02-one-arg.txt @@ -1 +1 @@ -/Users/agra/projects/sx/examples/ffi-objc-dsl-02-one-arg.sx:28:14: error: method calls on 'objc_class' runtime not yet supported (Phase 3/4) +addObject(21) = 42 diff --git a/tests/expected/ffi-objc-dsl-03-multi-keyword.exit b/tests/expected/ffi-objc-dsl-03-multi-keyword.exit index d00491f..573541a 100644 --- a/tests/expected/ffi-objc-dsl-03-multi-keyword.exit +++ b/tests/expected/ffi-objc-dsl-03-multi-keyword.exit @@ -1 +1 @@ -1 +0 diff --git a/tests/expected/ffi-objc-dsl-03-multi-keyword.txt b/tests/expected/ffi-objc-dsl-03-multi-keyword.txt index 7110918..4552404 100644 --- a/tests/expected/ffi-objc-dsl-03-multi-keyword.txt +++ b/tests/expected/ffi-objc-dsl-03-multi-keyword.txt @@ -1 +1 @@ -/Users/agra/projects/sx/examples/ffi-objc-dsl-03-multi-keyword.sx:27:14: error: method calls on 'objc_class' runtime not yet supported (Phase 3/4) +combine_and(7, 42) = 742 diff --git a/tests/expected/ffi-objc-dsl-04-mismatch.txt b/tests/expected/ffi-objc-dsl-04-mismatch.txt index 21f156e..eef0920 100644 --- a/tests/expected/ffi-objc-dsl-04-mismatch.txt +++ b/tests/expected/ffi-objc-dsl-04-mismatch.txt @@ -1 +1 @@ -/Users/agra/projects/sx/examples/ffi-objc-dsl-04-mismatch.sx:18:14: error: method calls on 'objc_class' runtime not yet supported (Phase 3/4) +/Users/agra/projects/sx/examples/ffi-objc-dsl-04-mismatch.sx:18:14: error: Obj-C selector for 'SxProbeMismatch.something_extra' has 2 keyword(s) but the call passes 1 argument(s); split the sx method name on '_' so it produces exactly 1 keyword(s), or override with `#selector("...")` once that lands (3.2)