ffi 3.2 A2: implement #selector("explicit:string") override

Make-green half of the cadence step started in A1. Wires the
`#selector` directive end-to-end:

- Lexer token `hash_selector` at src/token.zig + lookup row in
  src/lexer.zig.
- AST field `selector_override: ?[]const u8 = null` on
  `ForeignMethodDecl` (src/ast.zig).
- Parser block in src/parser.zig that mirrors
  `#jni_method_descriptor` — both occupy the same slot after the
  optional `-> ReturnType` and before the body/terminator. Not
  mutually exclusive at parse time.
- LSP semantic-token list (src/lsp/server.zig) updated.
- Lowering: `deriveObjcSelector` returns
  `{ sel, keyword_count, is_override }`. When `is_override` is true,
  the selector string is the user's literal and `keyword_count` is
  the colon count in that literal. Both `lowerObjcMethodCall` and
  `lowerObjcStaticCall` use the result.

Diagnostic policy when override colon-count ≠ call arity:

- Default mangling path: stays an error (`.err`). The user can fix
  the sx-side name to produce the right keyword count.
- Override path: downgrades to a warning (`.warn`). Rationale:
  Obj-C's `objc_msgSend` doesn't validate colon-vs-arg the way JNI's
  `GetMethodID` validates the descriptor — the runtime dispatches
  regardless and the wrong-arity case becomes silent calling-
  convention corruption. The compiler is the last line of defense
  for this typo class, but the warning preserves the override's
  escape-hatch character (deliberate mismatches still proceed).

Snapshot for `examples/ffi-objc-dsl-06-selector-override.sx` flips
from the pre-3.2 parser-error to working output:

  static override non-null: true

The mismatch diagnostic text in
`examples/ffi-objc-dsl-04-mismatch.sx`'s snapshot is updated to
drop the "once that lands (3.2)" phrasing now that 3.2 is here.

165/165 example tests.
This commit is contained in:
agra
2026-05-25 17:00:23 +03:00
parent a908ecf28f
commit 572ab12142
10 changed files with 98 additions and 37 deletions

View File

@@ -525,9 +525,18 @@ and an instance-method `NSDictionary.lookup` with override
so the snapshot captures the parser error and exit=1. Next commit so the snapshot captures the parser error and exit=1. Next commit
(A2) wires lexer/parser/AST/lowering and flips the snapshot. (A2) wires lexer/parser/AST/lowering and flips the snapshot.
Phase 3.2 A2 landed: `#selector("explicit:string")` override wired
end-to-end. Lexer token `hash_selector`, AST field
`selector_override: ?[]const u8` on `ForeignMethodDecl`, parser block
mirroring `#jni_method_descriptor`, lowering in `deriveObjcSelector`
returning `{ sel, keyword_count, is_override }`. Both
`lowerObjcMethodCall` and `lowerObjcStaticCall` honor the override;
arity-mismatch under the override path downgrades from `.err` to
`.warn` (the runtime doesn't validate colon-vs-arg the way JNI's
`GetMethodID` validates descriptors). Snapshot for
`ffi-objc-dsl-06-selector-override.sx` flipped to working output.
Open work, in roughly the order they make sense: Open work, in roughly the order they make sense:
- **Phase 3 step 3.2 — A2 (make-green)** — wire the `#selector`
token and override behavior. Snapshot flips to working output.
- **Phase 3 step 3.2 — B (golden mangling table)** — locked-in IR - **Phase 3 step 3.2 — B (golden mangling table)** — locked-in IR
fixture for the default mangling rule. fixture for the default mangling rule.
- **Phase 3 step 3.2 — C1..C5** — uikit.sx migration, one cluster - **Phase 3 step 3.2 — C1..C5** — uikit.sx migration, one cluster

View File

@@ -554,6 +554,7 @@ pub const ForeignMethodDecl = struct {
return_type: ?*Node, // null = void return_type: ?*Node, // null = void
is_static: bool = false, // true for `static name :: ...` is_static: bool = false, // true for `static name :: ...`
jni_descriptor_override: ?[]const u8 = null, // `#jni_method_descriptor("(Sig)Ret")` — JNI runtime only jni_descriptor_override: ?[]const u8 = null, // `#jni_method_descriptor("(Sig)Ret")` — JNI runtime only
selector_override: ?[]const u8 = null, // `#selector("explicit:string")` — Obj-C runtime only (Phase 3.2)
body: ?*Node = null, // sx-side implementation (defined-class only). null = `;`-terminated decl referencing inherited / external method. body: ?*Node = null, // sx-side implementation (defined-class only). null = `;`-terminated decl referencing inherited / external method.
}; };

View File

@@ -4491,32 +4491,41 @@ pub const Lowering = struct {
} }, ret_ty); } }, ret_ty);
} }
/// Derive the default Obj-C selector from a sx-side method name plus /// Resolve the Obj-C selector for a foreign-class method, honoring
/// its call-site arity (excluding self). Mangling: /// any `#selector("...")` override on the declaration. When an
/// override is present the selector string is the user's literal;
/// `keyword_count` is the `:` count in the literal (so callers can
/// still cross-check arity, downgrading the diagnostic to a
/// warning). When no override exists, the default mangling rule
/// runs:
/// - niladic: name verbatim (`length` → `length`). /// - niladic: name verbatim (`length` → `length`).
/// - arity ≥ 1: split the name on `_`; each piece becomes a keyword /// - arity ≥ 1: split the sx name on `_`; each piece becomes a
/// with a trailing `:` (`addObject` → `addObject:`, /// keyword with a trailing `:` (`addObject` → `addObject:`,
/// `combine_and` → `combine:and:`). /// `combine_and` → `combine:and:`).
/// Returns the selector string allocated on `self.alloc` and the fn deriveObjcSelector(self: *Lowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } {
/// number of keyword pieces (matters for arity validation; the if (method.selector_override) |sel| {
/// niladic case returns 0). var colons: usize = 0;
fn deriveObjcSelector(self: *Lowering, method_name: []const u8, arity: usize) struct { sel: []const u8, keyword_count: usize } { for (sel) |ch| {
if (ch == ':') colons += 1;
}
return .{ .sel = sel, .keyword_count = colons, .is_override = true };
}
if (arity == 0) { if (arity == 0) {
return .{ .sel = method_name, .keyword_count = 0 }; return .{ .sel = method.name, .keyword_count = 0, .is_override = false };
} }
// Each `_` in the sx name becomes a `:` (one-byte-for-one), plus // Each `_` in the sx name becomes a `:` (one-byte-for-one), plus
// one trailing `:` regardless of how many pieces. Piece count // one trailing `:` regardless of how many pieces. Piece count
// = (number of `_`) + 1. // = (number of `_`) + 1.
var pieces: usize = 1; var pieces: usize = 1;
for (method_name) |ch| { for (method.name) |ch| {
if (ch == '_') pieces += 1; if (ch == '_') pieces += 1;
} }
const out = self.alloc.alloc(u8, method_name.len + 1) catch unreachable; const out = self.alloc.alloc(u8, method.name.len + 1) catch unreachable;
for (method_name, 0..) |ch, i| { for (method.name, 0..) |ch, i| {
out[i] = if (ch == '_') ':' else ch; out[i] = if (ch == '_') ':' else ch;
} }
out[method_name.len] = ':'; out[method.name.len] = ':';
return .{ .sel = out, .keyword_count = pieces }; return .{ .sel = out, .keyword_count = pieces, .is_override = false };
} }
/// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol` /// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol`
@@ -4533,22 +4542,35 @@ pub const Lowering = struct {
span: ast.Span, span: ast.Span,
) Ref { ) Ref {
const arity = method_args.len; const arity = method_args.len;
const derived = self.deriveObjcSelector(method.name, arity); const derived = self.deriveObjcSelector(method, arity);
// Arity validation: the keyword count (number of `:` in the // Arity validation: the keyword count (number of `:` in the
// selector) must equal the number of args passed at the call // selector) must equal the number of args passed at the call
// site. For niladic methods the selector has no `:`; we already // site. For methods using the default mangling rule, a mismatch
// accept arity == 0 in the mangling above. // is an error because the user can fix the sx-side name. For
// `#selector("...")` overrides, the user has deliberately
// chosen the selector — downgrade to a warning so the build
// proceeds, but still surface the typo case (Obj-C's runtime
// doesn't validate colon-vs-arg, so this is the last defense).
if (arity > 0 and derived.keyword_count != arity) { if (arity > 0 and derived.keyword_count != arity) {
if (self.diagnostics) |d| { if (self.diagnostics) |d| {
d.addFmt( if (derived.is_override) {
.err, d.addFmt(
span, .warn,
"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)", span,
.{ fcd.name, method.name, derived.keyword_count, arity, arity }, "Obj-C selector \"{s}\" (override for '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string",
); .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity },
);
} else {
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(\"...\")`",
.{ fcd.name, method.name, derived.keyword_count, arity, arity },
);
return Ref.none;
}
} }
return Ref.none;
} }
const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void;
@@ -4583,18 +4605,27 @@ pub const Lowering = struct {
span: ast.Span, span: ast.Span,
) Ref { ) Ref {
const arity = method_args.len; const arity = method_args.len;
const derived = self.deriveObjcSelector(method.name, arity); const derived = self.deriveObjcSelector(method, arity);
if (arity > 0 and derived.keyword_count != arity) { if (arity > 0 and derived.keyword_count != arity) {
if (self.diagnostics) |d| { if (self.diagnostics) |d| {
d.addFmt( if (derived.is_override) {
.err, d.addFmt(
span, .warn,
"Obj-C selector for static call '{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)", span,
.{ fcd.name, method.name, derived.keyword_count, arity, arity }, "Obj-C selector \"{s}\" (override for static call '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string",
); .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity },
);
} else {
d.addFmt(
.err,
span,
"Obj-C selector for static call '{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(\"...\")`",
.{ fcd.name, method.name, derived.keyword_count, arity, arity },
);
return Ref.none;
}
} }
return Ref.none;
} }
const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void;

View File

@@ -94,6 +94,7 @@ pub const Lexer = struct {
.{ "#jni_method_descriptor", Tag.hash_jni_method_descriptor }, .{ "#jni_method_descriptor", Tag.hash_jni_method_descriptor },
.{ "#jni_env", Tag.hash_jni_env }, .{ "#jni_env", Tag.hash_jni_env },
.{ "#jni_main", Tag.hash_jni_main }, .{ "#jni_main", Tag.hash_jni_main },
.{ "#selector", Tag.hash_selector },
}; };
inline for (directives) |d| { inline for (directives) |d| {
const keyword = d[0]; const keyword = d[0];

View File

@@ -1512,6 +1512,7 @@ pub const Server = struct {
.hash_jni_method_descriptor, .hash_jni_method_descriptor,
.hash_jni_env, .hash_jni_env,
.hash_jni_main, .hash_jni_main,
.hash_selector,
=> ST.keyword, => ST.keyword,
.kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_, .kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_,

View File

@@ -1239,6 +1239,22 @@ pub const Parser = struct {
try self.expect(.r_paren); try self.expect(.r_paren);
} }
// Optional `#selector("explicit:string")` — explicit Obj-C selector override
// (Phase 3.2). Same slot as the JNI descriptor; they're not mutually
// exclusive at parse time though they belong to different runtimes.
var sel_override: ?[]const u8 = null;
if (self.current.tag == .hash_selector) {
self.advance(); // skip `#selector`
try self.expect(.l_paren);
if (self.current.tag != .string_literal) {
return self.fail("expected string literal selector after '#selector('");
}
const raw_sel = self.tokenSlice(self.current);
sel_override = raw_sel[1 .. raw_sel.len - 1];
self.advance();
try self.expect(.r_paren);
}
// Method body is optional: `;` → declaration (foreign or inherited // Method body is optional: `;` → declaration (foreign or inherited
// method we just want to call); `{ ... }` → sx-side implementation // method we just want to call); `{ ... }` → sx-side implementation
// for sx-defined classes. // for sx-defined classes.
@@ -1256,6 +1272,7 @@ pub const Parser = struct {
.return_type = return_type, .return_type = return_type,
.is_static = is_static, .is_static = is_static,
.jni_descriptor_override = desc_override, .jni_descriptor_override = desc_override,
.selector_override = sel_override,
.body = body_node, .body = body_node,
} }); } });
} }

View File

@@ -125,6 +125,7 @@ pub const Tag = enum {
hash_extends, // `#extends Alias;` inside a foreign-class body hash_extends, // `#extends Alias;` inside a foreign-class body
hash_implements, // `#implements Alias;` inside a foreign-class body hash_implements, // `#implements Alias;` inside a foreign-class body
hash_jni_method_descriptor, // `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override hash_jni_method_descriptor, // `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override
hash_selector, // `#selector("explicit:string")` per-method Obj-C selector override (Phase 3.2)
hash_jni_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic hash_jni_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic
hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity
triple_minus, // --- triple_minus, // ---

View File

@@ -1 +1 @@
/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) /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("...")`

View File

@@ -1 +1 @@
/Users/agra/projects/sx/examples/ffi-objc-dsl-06-selector-override.sx:20:26: error: expected ';' static override non-null: true