ffi 3.0: inst.method(args) DSL dispatch on #objc_class receivers

Implementation half of the cadence step started in the previous commit.
`lowerForeignMethodCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcMethodCall` helper
that derives the Obj-C selector from the sx method name and lowers to
`objc_msg_send` against the cached SEL slot (same intern path as
explicit `#objc_call`).

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

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

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

Tests `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,
04-mismatch}.sx` snapshots flip from the pre-3.0 bail diagnostic
(exit=1) to working output (exit=0 for cases 01-03) and the specific
keyword-count mismatch diagnostic for case 04. Each test follows the
established pattern from `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare a matching `#objc_class`, invoke the DSL
form. 163/163 tests; chess unaffected (JNI dispatch path untouched).
This commit is contained in:
agra
2026-05-25 16:10:22 +03:00
parent a593d150ca
commit 53fe73acda
9 changed files with 107 additions and 21 deletions

View File

@@ -472,23 +472,21 @@ JNI return + parameter type validation lives in lowering with source-
spanned diagnostics; Call<T>Method 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 /

View File

@@ -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("<init>", "(args)V") + NewObject(env,

View File

@@ -1 +1 @@
1
0

View File

@@ -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

View File

@@ -1 +1 @@
1
0

View File

@@ -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

View File

@@ -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

View File

@@ -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)