From 572ab12142c164b1c669acc6f5f7990315a601fa Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 25 May 2026 17:00:23 +0300 Subject: [PATCH] ffi 3.2 A2: implement `#selector("explicit:string")` override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- current/CHECKPOINT-FFI.md | 13 ++- src/ast.zig | 1 + src/ir/lower.zig | 95 ++++++++++++------- src/lexer.zig | 1 + src/lsp/server.zig | 1 + src/parser.zig | 17 ++++ src/token.zig | 1 + tests/expected/ffi-objc-dsl-04-mismatch.txt | 2 +- .../ffi-objc-dsl-06-selector-override.exit | 2 +- .../ffi-objc-dsl-06-selector-override.txt | 2 +- 10 files changed, 98 insertions(+), 37 deletions(-) diff --git a/current/CHECKPOINT-FFI.md b/current/CHECKPOINT-FFI.md index e453c1d..85827ad 100644 --- a/current/CHECKPOINT-FFI.md +++ b/current/CHECKPOINT-FFI.md @@ -525,9 +525,18 @@ and an instance-method `NSDictionary.lookup` with override so the snapshot captures the parser error and exit=1. Next commit (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: -- **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 fixture for the default mangling rule. - **Phase 3 step 3.2 — C1..C5** — uikit.sx migration, one cluster diff --git a/src/ast.zig b/src/ast.zig index 36b94a3..8c58a27 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -554,6 +554,7 @@ pub const ForeignMethodDecl = struct { return_type: ?*Node, // null = void is_static: bool = false, // true for `static name :: ...` 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. }; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 7798fab..b8fc904 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -4491,32 +4491,41 @@ 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: + /// Resolve the Obj-C selector for a foreign-class method, honoring + /// 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`). - /// - arity ≥ 1: split the name on `_`; each piece becomes a keyword - /// with a trailing `:` (`addObject` → `addObject:`, + /// - arity ≥ 1: split the sx 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 } { + fn deriveObjcSelector(self: *Lowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } { + if (method.selector_override) |sel| { + var colons: usize = 0; + for (sel) |ch| { + if (ch == ':') colons += 1; + } + return .{ .sel = sel, .keyword_count = colons, .is_override = true }; + } 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 // one trailing `:` regardless of how many pieces. Piece count // = (number of `_`) + 1. var pieces: usize = 1; - for (method_name) |ch| { + 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| { + 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 }; + out[method.name.len] = ':'; + return .{ .sel = out, .keyword_count = pieces, .is_override = false }; } /// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol` @@ -4533,22 +4542,35 @@ pub const Lowering = struct { span: ast.Span, ) Ref { 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 // 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. + // site. For methods using the default mangling rule, a mismatch + // 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 (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 }, - ); + if (derived.is_override) { + d.addFmt( + .warn, + span, + "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; @@ -4583,18 +4605,27 @@ pub const Lowering = struct { span: ast.Span, ) Ref { 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 (self.diagnostics) |d| { - 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(\"...\")` once that lands (3.2)", - .{ fcd.name, method.name, derived.keyword_count, arity, arity }, - ); + if (derived.is_override) { + d.addFmt( + .warn, + span, + "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; diff --git a/src/lexer.zig b/src/lexer.zig index ab3e93d..9566355 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -94,6 +94,7 @@ pub const Lexer = struct { .{ "#jni_method_descriptor", Tag.hash_jni_method_descriptor }, .{ "#jni_env", Tag.hash_jni_env }, .{ "#jni_main", Tag.hash_jni_main }, + .{ "#selector", Tag.hash_selector }, }; inline for (directives) |d| { const keyword = d[0]; diff --git a/src/lsp/server.zig b/src/lsp/server.zig index 9509137..293c028 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -1512,6 +1512,7 @@ pub const Server = struct { .hash_jni_method_descriptor, .hash_jni_env, .hash_jni_main, + .hash_selector, => ST.keyword, .kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_, diff --git a/src/parser.zig b/src/parser.zig index d94e626..bad8f67 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1239,6 +1239,22 @@ pub const Parser = struct { 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 we just want to call); `{ ... }` → sx-side implementation // for sx-defined classes. @@ -1256,6 +1272,7 @@ pub const Parser = struct { .return_type = return_type, .is_static = is_static, .jni_descriptor_override = desc_override, + .selector_override = sel_override, .body = body_node, } }); } diff --git a/src/token.zig b/src/token.zig index 3d5e14b..179a90e 100644 --- a/src/token.zig +++ b/src/token.zig @@ -125,6 +125,7 @@ pub const Tag = enum { hash_extends, // `#extends 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_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_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity triple_minus, // --- diff --git a/tests/expected/ffi-objc-dsl-04-mismatch.txt b/tests/expected/ffi-objc-dsl-04-mismatch.txt index eef0920..05dfdf7 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: 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("...")` diff --git a/tests/expected/ffi-objc-dsl-06-selector-override.exit b/tests/expected/ffi-objc-dsl-06-selector-override.exit index d00491f..573541a 100644 --- a/tests/expected/ffi-objc-dsl-06-selector-override.exit +++ b/tests/expected/ffi-objc-dsl-06-selector-override.exit @@ -1 +1 @@ -1 +0 diff --git a/tests/expected/ffi-objc-dsl-06-selector-override.txt b/tests/expected/ffi-objc-dsl-06-selector-override.txt index 1a2d01b..04789a5 100644 --- a/tests/expected/ffi-objc-dsl-06-selector-override.txt +++ b/tests/expected/ffi-objc-dsl-06-selector-override.txt @@ -1 +1 @@ -/Users/agra/projects/sx/examples/ffi-objc-dsl-06-selector-override.sx:20:26: error: expected ';' +static override non-null: true