0ede0973f452ab9bb45deed1bfd57024f0ff676c
109 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0ede0973f4 |
ffi issue-0048: lazyLowerFunction isolates pack-fn mono state
`lazyLowerFunction` now saves and nulls `pack_arg_nodes`, `pack_param_count`, `pack_arg_types`, and `inline_return_target` before lowering the callee's body, then restores them via defer. Same shape as the save/restore already in `createComptimeFunction` (issue-0046 fix). Without this, a lazily lowered regular fn called from inside a pack-fn mono inherited the outer pack maps, and the `<pack_name>.len` intercept in `lowerFieldAccess` constant-folded the callee's same-named param to the outer mono's arity. `examples/173-pack-bare-args-cross-call.sx` now passes; previously- green tests untouched. 213/213. |
||
|
|
d99c0fdb2b |
ffi M5.A.next.4A.bare.4.B: tryLowerReflectionCall splits static vs dynamic
Fix for the silent .s64 fall-through in `type_name(<dynamic-arg>)`.
`tryLowerReflectionCall` now splits on `isStaticTypeArg(node)`:
- Static (type_expr / identifier / pack_index_type_expr / pointer
/ array / slice / optional / many_pointer / function_type_expr
/ tuple_literal / call) → fold to const_string at lower time
(today's fast path).
- Dynamic (index_expr, field_access, runtime locals, anything
else) → emit `callBuiltin(.type_name, [arg_ref])`. The interp's
arm (commit
|
||
|
|
5a4a19b3ab |
ffi M5.A.next.4A.bare.1.B: bare $args lowers to []Type slice value
Step 4A final-slice fix. Bare `$<pack_name>` (no `[<int>]`)
in expression position now parses + lowers to a comptime
`[]Type` slice value carrying one `const_type(TypeId)` per
pack element.
Plumbing:
- src/ast.zig: new `ComptimePackRef { pack_name }` node +
`comptime_pack_ref` variant in Data.
- src/parser.zig: `parsePrimary`'s `$` arm makes `[` optional
after the pack name. With `[<int>]` → existing
`pack_index_type_expr` (single Type value). Without → new
`comptime_pack_ref` (whole pack as []Type).
- src/sema.zig: adds the no-op switch arms for the new node
in `analyzeNode` and `findNodeAtOffset`.
- src/ir/lower.zig: `lowerExpr` arm reads `pack_arg_types[name]`
and calls `buildPackSliceValue(arg_tys)`. The helper allocas
a `[N x Any]` array, emits one `const_type(arg_tys[i])` per
slot, then a slice `{data_ptr, len}` aggregate. No active
binding → focused diagnostic + null slice placeholder. The
IR slice element type is `Any` (matches the today's
`Type → .any` mapping in type_bridge); the interp stores
raw `.type_tag` Values directly (NOT Any-boxed) so
`args[i]` at interp time reads a Type value.
- src/ir/emit_llvm.zig: relaxed `const_type` to silently emit
undef-i64 instead of the previous stderr-noisy bail. Storage
of Type values in runtime aggregates is harmless (undef in,
undef out). Use-site misuse is caught by the bails on
type_name/type_eq/has_impl and the bitcast guard.
`examples/170-pack-bare-value.sx` flips from the parse-error
lock-in to "0/1/3/4" — four call shapes of `len_of(..$args) ->
s64 { list := $args; return list.len; }`. The slice's `.len`
field carries the per-mono pack arity.
210/210 example tests + `zig build test` green.
The remaining 4A.bare slices (4 and 5) — resolveTypeArg
silent-arm fix for index_expr + smoke test of a real builder
walking $args — are separate commits per the cadence rule.
|
||
|
|
fd03b5812f |
ffi M5.A.next.4.3: $args[$i] in expression position — source construction
Final slice of the .type_tag activation. Sx code can now
construct Type values through the `$<pack>[<int_literal>]`
syntax in expression position. Lowering emits the new
`const_type(TypeId)` opcode; the interp materialises
`Value.type_tag(TypeId)`; reflection intrinsics + cmp_eq
read it kind-honestly.
Plumbing:
- src/parser.zig: `parsePrimary` accepts `$<ident>[<int_literal>]`
at the front of every expression. Emits a `pack_index_type_expr`
AST node — same node already used in TYPE positions in step 3,
now extended to expression positions.
- src/ir/lower.zig: two places teach the new node.
- `lowerExpr` arm: looks up `pack_arg_types[name][index]`, emits
`builder.constType(arg_tys[index])`. OOB / no-binding paths
emit a focused diagnostic + a `constType(.void)` placeholder
(loud failure preserves silent-error budget).
- `resolveTypeArg` arm: the same lookup, but returns the
TypeId directly. Used by the lower-time fast paths in
`tryLowerReflectionCall` + `tryConstBoolCondition` so
`type_name($args[0])`, `type_eq($args[0], s64)`, and
`has_impl(...)` all see the bound TypeId rather than
falling through to the `.s64` default that the silent-arm
rule forbids.
The two arms ensure both runtime AND compile-time paths use
the same source-of-truth (`pack_arg_types`), so per-mono
dispatch via `inline if type_eq($args[0], s64) { ... }` folds
at compile time as expected.
`examples/169-pack-value-dispatch.sx` exercises both shapes:
- `type_name($args[0])` returns the per-mono concrete type
name ("s64", "string", "f64").
- `inline if type_eq($args[0], s64) { ... }` ladder dispatches
per-mono ("got s64", "got string", "got bool", "got other").
209/209 example tests + `zig build test` green.
What's now possible end-to-end:
show :: (..$args) -> string => type_name($args[0]);
show(42) // "s64"
show("hi") // "string"
describe :: (..$args) -> string {
inline if type_eq($args[0], s64) { return "got s64"; }
...
}
The "by the book" activation is complete:
- foundation (const_type opcode, interp variant, helpers) — 4.0
- interp reflection arms (type_name / type_eq / has_impl) — 4.1
- box_any/display audit + bitcast guard — 4.2
- source-language construction via $args[$i] — 4.3
Step 5 (generic Into(Block) impl in stdlib) is now fully
unblocked — its trampoline body can interpolate per-mono types
both in type positions AND in expression positions.
|
||
|
|
8b457ffc44 |
ffi M5.A.next.3b: type_eq + has_impl comptime intrinsics
Step 3 second slice. Adds two reflection builtins used by pack-fn bodies to branch on type identity / protocol membership at compile time. type_name already existed (lower.zig:8693); reused as-is. type_eq(T1, T2) -> bool structural TypeId equality has_impl(P, T) -> bool T has a reachable impl for P Both are wired through `tryConstBoolCondition` so the inline-if ladder folds them at lower time — `inline if type_eq(...)` / `inline if has_impl(...)` collapse to a single branch with no runtime instructions, perfect for guard-based dispatch inside pack-fn bodies. `has_impl`'s protocol arg accepts two shapes: - plain protocol name: `has_impl(Allocator, CAllocator)` → walks `protocol_thunk_map["Allocator\x00CAllocator"]`. - parameterised call: `has_impl(Into(Block), s64)` → builds the param_impl_map key `"Into\x00Block\x00s64"` and checks containment. The protocol type-args resolve through `resolveTypeArg` so type aliases, generics, and pack-indexed types all work as protocol args. `computeHasImpl` is the shared implementation between the runtime builtin path and the `tryConstBoolCondition` fast path so both branches stay in sync. `examples/168-pack-reflection-intrinsics.sx` exercises every shape: - type_name for primitive types. - type_eq with both equal + unequal cases, including pointer types (s64 vs *s64). - inline-if folding type_eq. - has_impl with a real plain-protocol impl (Allocator/CAllocator → true; Allocator/s64 → false). - has_impl with a user-defined parameterised protocol (Wrap(s64)/s32 → true; mismatched target args → false). 208/208 example tests + `zig build test` green. Caveat: plain-protocol has_impl uses `protocol_thunk_map` which is lazily populated when an `xx` cast or protocol dispatch creates the thunks. For a static check before any dispatch, that could false-negative. Allocator/CAllocator works in 168 because stdlib's startup uses CAllocator through the Allocator protocol — the thunks already exist by the time has_impl runs. A more robust static check (walk fn_ast_map for "<T_name>.<method>" entries against the protocol's method list) is deferred to a follow-up if needed. LSP "undefined variable" warnings on type names in expression position (s64, *s64, Wrap(s64), etc. passed to type_eq / has_impl) are cosmetic — sema doesn't know these intrinsics accept types as args. Tracked separately. |
||
|
|
9137f4158d |
ffi M5.A.next.3a.C: $args[$i] in fn-pointer type literals
Adds `resolveFunctionTypeWithBindings` so `function_type_expr` in a binding-aware context — local var annotations, return types, nested type expressions — recursively resolves through the active pack bindings. Without this, the fall-through to `type_bridge.resolveAstType` lost pack context and the new `pack_index_type_expr` arm spammed the "outside pack-aware context" diagnostic (the function still worked by accident thanks to the `.s64` fallback). Plumbing: - `resolveTypeWithBindings` adds a `function_type_expr` case in both the bindings-active branch and the fallthrough switch (the same shape as `closure_type_expr`). - `resolveFunctionTypeWithBindings` recursively resolves each param + return type with bindings, then calls `functionTypeCC` with the AST's calling convention. `examples/167-pack-type-fnptr.sx` exercises the pattern step 5's trampoline needs: fp : (*void, $args[0]) -> $args[1] = double_s64; return fp(null, args[0]); Output: 14 (= 7*2 via the typed fn-pointer). 207/207 example tests + `zig build test` green. |
||
|
|
3df58febb6 |
ffi M5.A.next.3a.B: $args[$i] in type positions — parser + resolver
Step 3 first slice. `$<pack>[<int_literal>]` now parses in
every type position and resolves against the active pack
binding (`pack_arg_types` map set up by `monomorphizePackFn`).
Plumbing:
- src/ast.zig: new `PackIndexTypeExpr { pack_name, index }`
AST node + `pack_index_type_expr` variant in `Data`.
- src/parser.zig: in `parseTypeExpr`'s `$<ident>` arm, peek
for `[`. If found, parse a non-negative `int_literal` index
followed by `]` and emit a `pack_index_type_expr` node.
Plain `$T` / `$T/Eq` paths unchanged.
- src/ir/lower.zig::resolveTypeWithBindings: handles
`pack_index_type_expr` first — looks up the pack name in
`pack_arg_types`, returns `arg_tys[index]` when in range.
OOB and "no active pack binding" cases emit focused
diagnostics at the node span.
- src/ir/type_bridge.zig::resolveAstType: handles the same
node but falls back to `.s64` with a stderr note — the bare
type_bridge has no access to lowering state. Pack-aware
callers route through `resolveTypeWithBindings`.
- src/sema.zig: adds `pack_index_type_expr` to the no-op
arms in `analyzeNode` and `findNodeAtOffset` so the sema
pass doesn't reject the new variant.
Tests:
- examples/165-pack-type-position.sx (lock-in from
|
||
|
|
248d6e669c |
ffi issue-0046 fix: save/restore outer state in createComptimeFunction
`createComptimeFunction` wraps a comptime expression into a fresh fn that the interp executes in isolation. The wrapper must not inherit the enclosing call's lowering state — any leaked slot, binding, or scope flag corrupts the wrapper's own lowering. Pre-fix, only `func` / `current_block` / `inst_counter` / `scope` / `current_ctx_ref` were saved. Specifically NOT saved: - `inline_return_target` — set by `lowerComptimeCall` for an outer comptime body with `return X;`. The wrapper's body was lowering through this slot, routing the wrapper's `ret` into a basic block from a different function. - `pack_arg_nodes`, `pack_param_count`, `pack_arg_types` — active during a pack-fn mono's body lowering. (Pack-fn face of 0046 was already fixed by step 2b moving pack-fn calls off the inline path; these saves close a latent cross-contamination if any future pack-mono body invokes the comptime interp.) - `comptime_param_nodes` — active during an outer `lowerComptimeCall` to bind `$fmt`-style substitutions. - `block_terminated`, `target_type`, `func_defer_base` — fn- local flags that the wrapper's lowering needs fresh. All eight now save/restore in `createComptimeFunction`. The wrapper runs in a clean state. `examples/issue-0046.sx` flips from the non-deterministic interp panic to "inside\n" + "n=42\n". 204/204 example tests + `zig build test` green. Issue file marked FIXED with a pointer to the regression test. |
||
|
|
159f898ffe |
ffi M5.A.next.2b.fu1.B: mixed comptime+pack — mono with comptime values folded into mangle
Fixes follow-up #1 from step 2b. Pack-fns can now mix non-pack comptime params with the trailing pack: tagged :: ($tag: s32, ..$args) -> s64 { return tag * 100 + args.len; } `isPackFn` relaxed to "exactly one trailing pack + any number of non-pack comptime params". The mono path takes over. Plumbing in src/ir/lower.zig: - `lowerPackFnCall` walks fd.params + call_node.args in lockstep: comptime non-pack args fold into the mangle (`__ct_<value>` segments); non-comptime non-pack args contribute to the runtime arg-type list; remaining call args populate the pack expansion. - `appendComptimeValueMangle` mangles int / bool / float / string literals stably. Strings hash to keep the symbol short. Distinct comptime values get distinct monos. - `monomorphizePackFn` takes `call_node` so it can read comptime call args. Skips comptime non-pack params when building the runtime IR signature. Binds each comptime non-pack param both as a `comptime_param_nodes` entry (for `#insert`) AND as a runtime local via alloca+store (for bare-name body access). `examples/164-pack-mixed-comptime.sx` flips from "unresolved 'tag'" to `703` / `900`. Two calls of `tagged` with different comptime tags get distinct monos (`tagged__ct_7__pack_...` and `tagged__ct_9__pack`). This is the load-bearing prerequisite for step 6 of the plan (stdlib `print` / `format` refactor to `(\$fmt, ..\$args)`). Out of scope: - Non-literal comptime args. `appendComptimeValueMangle` degrades them to `?` (so two distinct non-literal expressions in the same call slot would collide). Acceptable since literal args are the only common case; non-literal would need comptime evaluation to determine the value. 203/203 example tests + `zig build test` green. |
||
|
|
d30d566397 |
ffi M5.A.next.2b.fu34.B: pack-mono materialises []Any slice for bare args
Fixes follow-ups #3 (bare `args` reference) and #4 (`args[<runtime_int>]`) from step 2b. The pack-mono now materialises an `[]Any` slice value for the pack name at body entry: each pack-param slot is loaded, boxed via `boxAny`, and stored into a stack [N x Any] array; the slice {data_ptr, len} binds to the pack name in scope. Plumbing in src/ir/lower.zig: - `materialisePackSlice(scope, pack_name, slot_refs, arg_types)` — new helper that emits the array alloca + box+store loop + slice alloca + bind. Empty-pack case (N == 0) emits {null, 0} directly. - `monomorphizePackFn` captures the pack-param slot Refs as they bind, then calls `materialisePackSlice` after binding so the slice load can pull each param value. After: `args` (bare) resolves as `[]Any` and forwards to slice-typed helpers; `args[<runtime_int>]` lowers through the standard slice-indexing path, element type `Any`. Per-position type info is lost via Any boxing — that is the inherent cost of treating a heterogeneous pack as a uniform value. Literal- indexed access still routes through `packArgNodeAt` and keeps the concrete per-position types. `examples/162-pack-bare-args.sx` flips from "unresolved 'args'" to `3` (forwarded to `log_count(items: []Any)` which returns `items.len`). `examples/163-pack-runtime-index.sx` flips from the LLVM verifier crash to `4` (while-loop over `args.len`, indexing each `args[i]` runtime). 202/202 example tests + `zig build test` green. |
||
|
|
2e0b97aaa5 |
ffi M5.A.next.2b.fu2.C: heterogeneous pack ret + OOB diagnostic
Two follow-on fixes for follow-up #2 (generic pack-fn return). (1) `pack_arg_types` — a new type-only pack binding consulted by `inferExprType` for `<pack_name>[<int_literal>]`. The earlier `pack_arg_nodes`-via-synthesized-idents path lost the type during return-type inference because the synthesized idents ("__pack_args_0" etc.) only resolve once the mono scope is set up — but the inference runs BEFORE scope setup. Now `monomorphizePackFn` installs `pack_arg_types[<pack>] = arg_types` alongside the existing nodes/count maps, and `inferExprType` consults it directly. `foo(..$args) -> $R => args[2]` called as `foo(42, 3.2, "hello")` now correctly returns "hello" (string) — the third element- typed pick threads through inference to the mono ret_ty. (2) `diagPackIndexOOB` — focused diagnostic for `args[<lit>]` where the literal exceeds the pack arity. Pre-fix the substitution returned null and the standard slice-indexing fall-through emitted "unresolved args" — burying the real cause. Now: "pack index 2 out of bounds: 'args' has 1 element" at the index span. Tests: - `examples/160-pack-hetero-ret.sx` — generic `$R` with non- zeroth heterogeneous pick (returns "hello"). - `examples/161-pack-index-oob.sx` — call passes 1 arg but body indexes args[2]; locks in the OOB diagnostic shape. 200/200 example tests + `zig build test` green. |
||
|
|
c917f92509 |
ffi M5.A.next.2b.fu2.B: generic pack-fn return — infer ret_ty from body
Fix for follow-up #2 from step 2b. When a pack-fn declares `(..\$args) -> \$R` (return type a generic name), the mono now infers ret_ty from the body's first explicit `return X;` or falls back to the tail expression of an arrow-form body. Plumbing in src/ir/lower.zig: - `inferPackBodyReturnType(body)` walks the body via the existing `findReturnValueType` helper (return stmts) and falls through to `inferExprType` on the tail expression for arrow-form / tail-expr bodies. - `monomorphizePackFn` now pre-installs `pack_arg_nodes` and `pack_param_count` BEFORE resolving the return type so the inference can substitute `args[<lit>]` to call-site arg AST nodes during type lookup. - Generic-ret detection: `fd.return_type` AST node is a `type_expr` with `is_generic = true`. Concrete returns stay on the standard `resolveReturnType` path. `examples/159-pack-generic-ret.sx` flips from `0 0` (silent- zero coercion through opaque struct ret_ty) to `42 99`. 198/198 example tests + `zig build test` green. |
||
|
|
79896188eb |
ffi M5.A.next.2b: per-call-shape monomorphisation for pack-fns
Pack-fns (`isPackFn(fd) == true` — last param `is_variadic AND
is_comptime`, no other comptime params) now emit ONE
monomorphised function per unique call-site signature. Repeat
calls with the same arg-type tuple share the mono; distinct
shapes get distinct symbols. Pre-2b each call inlined a fresh
body copy into the caller's basic block; IR size grew linearly
in call sites.
Plumbing in `src/ir/lower.zig`:
- `isPackFn(fd)` — true when the only comptime param is a
trailing pack. Mixed `($fmt, ..$args)` shapes stay on the
inline `lowerComptimeCall` path (different substitution
mechanism for the comptime non-pack param; deferred).
- `lowerPackFnCall(fd, call_node)`:
- Builds a mangled name `<fn_name>__pack__<arg_types>` from
call-site `inferExprType` results. Distinct shapes get
distinct symbols.
- Cache-checks `lowered_functions`; calls
`monomorphizePackFn` on miss.
- Lowers call args, then re-fetches the func pointer (the
fetch BEFORE arg lowering would invalidate after any
transitively-triggered module.functions.items realloc),
prepends ctx if needed, coerces, emits direct call.
- `monomorphizePackFn(fd, mangled, arg_types)`:
- Mirrors `monomorphizeFunction` for the standard fn build:
save state, build param list (ctx + fixed prefix + N pack
params with synthesised names `__pack_<name>_<i>`),
`beginFunction`, entry block, bind params to scope.
- Installs `pack_arg_nodes[<name>]` with synthesised AST
identifier nodes pointing at the pack-param slots so the
body's `args[<int_literal>]` substitutes through the
existing 2a.B mechanism — substitution resolves to the
mono's own param slot loads.
- Installs `pack_param_count[<name>] = N` so the body's
`args.len` resolves to a compile-time constant via a new
intercept in `lowerFieldAccess` (and the parallel arm in
`inferExprType`).
- Lowers the body with `inline_return_target = null` so
`return X;` emits a real `ret X` instead of the inline-slot
routing — the mono is a real fn now.
- Routed at three call sites: each `if (hasComptimeParams(fd))
{ return self.lowerComptimeCall(...); }` now first checks
`isPackFn(fd)` and routes to `lowerPackFnCall` when true.
Lifetime gotcha caught and fixed: `params.items` is stored by
reference in `Function.init` (no copy), so the local
`ArrayList(Function.Param)` must NOT be deinit'd in
`monomorphizePackFn` — matches the leak convention already used
by `monomorphizeFunction`.
`examples/158-pack-mono-dedup.sx` confirms the dedup
end-to-end: `count(), count(1), count(2), count(1,2,3),
count("x", true)` produces `0 1 1 3 2` at runtime AND emits
exactly 4 monos in IR (`count__pack`, `count__pack_s64`,
`count__pack_s64_s64_s64`, `count__pack_string_bool`) — the
two s64 calls share. `args.len` resolves to the comptime
constant N inside each mono.
`examples/156-pack-typed-index.sx` and
`examples/157-pack-if-return.sx` continue to pass unchanged.
Out of scope:
- Mixed `$fmt + ..$args` shapes (stays on inline path).
- Generic `$R` return types (concrete returns only).
- Bare `args` reference (passing the slice as a whole).
- `args[<runtime_int>]` (non-literal index).
197/197 example tests + `zig build test` green.
|
||
|
|
e6d6903708 |
ffi M5.A.next.2a.D: inline-return uses CFG terminator, not block_terminated
Fixes the regression locked in by 2a.C (commit
|
||
|
|
cd367847a9 |
ffi M5.A.next.2a.B: pack typed indexing — args[$i] substitutes call arg
Pack-fn bodies that index the pack via `args[<int_literal>]` now resolve to the i-th call-site argument's lowered value directly, propagating the call arg's concrete type instead of the boxed `Any` that the `[]Any` slice path returns. New plumbing in `src/ir/lower.zig`: - `pack_arg_nodes: ?std.StringHashMap([]const *const Node)` on Lowering. Maps a pack param name (e.g. "args") to the slice of call-site arg AST nodes. - `lowerComptimeCall` populates the map when the variadic param is heterogeneous (`is_variadic AND is_comptime`, i.e. the `..$args` form). Plain `args: ..Any` keeps the existing `[]Any` slice path so stdlib's `format`/`print` continue unchanged. The map is saved/restored across nested calls mirroring `comptime_param_nodes`. - `packArgNodeAt(ie)` returns the call-arg node when an index_expr matches `<pack_name>[<comptime_int_literal>]` with the index in range; null otherwise (fall through to standard slice indexing for runtime indices or non-pack bases). - `lowerIndexExpr` checks `packArgNodeAt` first; on a hit it lowers the call arg node directly. `inferExprType`'s `index_expr` arm does the parallel check so AST-level type inference (e.g., for field-access type checking) sees the concrete call-arg type. `examples/156-pack-typed-index.sx` flips from "field 'x' not found on type 'Any'" to `7` — `args[0].x` now resolves through the concrete `Point` type instead of Any. Out of scope (deferred): non-literal comptime indices (`args[$i]` where `$i` is an arbitrary comptime expression); `$args[$i]` in type positions (step 3); per-mono mangling (monomorphisation stays inline-only). 195/195 example tests + `zig build test` green. |
||
|
|
9e78790ebf |
ffi issue-0045 fix: inline-return slot for comptime-call bodies
`lowerComptimeCall` now scans the body for `return` statements
via `fnBodyHasReturn`. When found, it allocates a stack slot
typed to the fn's return type and installs it as
`self.inline_return_target` before lowering the body.
`lowerReturn` checks `inline_return_target` first:
- If set, it stores the coerced return value into the slot,
drains pending defers, sets `block_terminated = true`, and
returns without emitting a `ret` into the caller's basic
block.
- Otherwise it emits the standard `ret` as before.
After the body lowers, the inliner either returns the
tail-expression value (existing fast path — bodies with no
`return` skip the slot entirely) or loads the slot when
`block_terminated` is set.
Why the bug was invisible until now: `format`/`print` and
every other stdlib comptime fn use arrow form (`=> expr`) or
`#insert`-only bodies — no `return` statement, no path through
`lowerReturn`. Step 1.b of the pack feature made `..$args`
parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first
comptime-fn body to take the `return`-with-trailing-statements
path, surfacing the LLVM verifier crash.
`examples/issue-0045.sx` flips from the lock-in failure to
`42`. 194/194 example tests + `zig build test` green.
|
||
|
|
08feb6040b |
ffi M5.A.next.1d.B: pack impl matching — bind $args + $R per call
Pack-shaped impls (`impl P(...) for Closure(..$args) -> $R`) now match concrete closure sources at xx resolution time. Concrete impls keep their priority — pack matching only fires on a concrete-key miss in `param_impl_map`. New plumbing in src/ir/lower.zig: - `PackParamImplEntry` carries the pack-shaped source TypeId plus the pack-var and ret-var names extracted from the impl AST's `target_type_expr`. `registerParamImpl` detects pack-shaped sources via `pack_start != null` on the resolved closure type and additionally registers in a new `param_impl_pack_map` keyed by `"Proto\x00<arg_mangled>"` (no source suffix). - `tryUserConversion` re-shapes the concrete lookup so the pack path runs on miss. `tryPackImplMatch` walks the pack entries, verifies the source's fixed prefix matches the impl's prefix, binds the pack-var to the source's tail param TypeIds, binds the ret-var (when the impl's return is generic) to the source return, and monomorphises the convert method. Mangled name stays keyed on the concrete source so distinct call shapes monomorphise separately. - `pack_bindings: ?StringHashMap([]const TypeId)` is saved/ restored around monomorphisation, mirroring `type_bindings`. - `resolveClosureTypeWithBindings` handles the closure_type_expr node during type resolution: when the closure carries a `pack_name` AND `pack_bindings` has a binding for it, the bound TypeIds are appended after the fixed prefix and the result is a concrete (non-pack) closure type — so the impl body's `self: Closure(..$args) -> $R` substitutes to the concrete source closure during monomorphisation. Without an active binding, the pack shape is preserved. `examples/155-pack-impl-match.sx` flips from the "no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to "pack impl match ok": one user-declared `impl Into(Block) for Closure(..$args) -> $R` covers a `Closure(s32, bool) -> bool` source that stdlib has no hand-rolled impl for. Constructed Block isn't invoked (invoke=null) — the test exercises only the matching + monomorphisation, not the trampoline (step 5 of the plan). Existing concrete-impl paths unchanged: 95-objc-block-noop, 96-objc-block-multi-arg, and stdlib's hand-rolled `Into(Block) for Closure(bool) -> void` continue to pass through the concrete map first. Same-file duplicate pack impls diagnose at registration; cross-module visibility and multi-pack-impl specificity stay TODOs (matching the deferred Phase 5 work on the concrete path). 193/193 example tests + `zig build test` green. |
||
|
|
07f25689ff |
ffi M5.A revert: drop compiler synthesis, require explicit Into(Block) impls
Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.
New design (strict mode):
1. Stdlib's modules/std/objc_block.sx hand-rolls
`__block_invoke_void` + `Into(Block) for Closure() -> void` and
the same pair for `Closure(bool) -> void` (restored from M5.A.2).
These are readable reference implementations of the bridge ABI.
2. The compiler intercept fires NO synthesis — instead, when
`tryUserConversion` can't find a reachable `Into(Block)` impl for
the closure's signature, it emits a focused diagnostic:
"no `Into(Block) for <Closure-sig>` impl — add a per-signature
`__block_invoke_<sig>` trampoline + Into impl alongside the
existing ones in modules/std/objc_block.sx, or declare it in
your own code"
3. Per-signature declarations live in stdlib (for common signatures)
or in user code (for app-specific ones). 96-objc-block-multi-arg
now demonstrates the user-side pattern in-file — it declares its
own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
*void) -> void` impl alongside its main().
Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
not silent fallthrough.
Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.
190/190 example tests pass; chess on iOS-sim green.
|
||
|
|
22c087ff35 |
ffi M5.A: compiler-synthesised __block_invoke_<sig> trampolines
`xx closure : Block` casts now bypass the user-space Into(Block)
protocol path entirely. The compiler intercepts in
`tryUserConversion` BEFORE the Into lookup, detects when src is
`Closure(...)` and dst is `Block`, and emits:
1. A C-ABI trampoline `__block_invoke_<sig>` (deduped per closure
signature via `block_invoke_trampolines` map). Body matches the
existing hand-rolled `__block_invoke_void` exactly: load
block_self struct, extract sx_env (field 5) + sx_fn (field 6),
call sx_fn(__sx_default_context, sx_env, ...user_args), return.
2. Inline Block-struct construction at the cast site:
`Block { isa = &_NSConcreteStackBlock, flags=0, reserved=0,
invoke = &__block_invoke_<sig>,
descriptor = &__sx_block_descriptor,
sx_env = closure.env, sx_fn = closure.fn_ptr }`
Signature mangling: compact codes — `v` void, `b` bool, `i` s32,
`q` s64, `f` f32, `d` f64, `c/C/s/S/I/Q` for other ints, `p` for
pointers/aggregates that lower to a machine word. Return first,
then params underscore-joined. `Closure() -> void` mangles to `v`;
`Closure(bool) -> void` mangles to `v_b`.
Loud failures at the cast site:
- `Block` struct missing → "requires #import \"modules/std/objc_block.sx\";"
- `_NSConcreteStackBlock` extern missing → same diagnostic.
- `__sx_block_descriptor` global missing → same.
- `__sx_default_context` missing inside the trampoline emitter →
compiler-bug diagnostic (the scan pass should always register it).
The existing hand-rolled stdlib impls (`__block_invoke_void`,
`__block_invoke_bool`, the two `Into(Block) for Closure(...)`
impls) are now redundant — the compiler-synthesised trampoline
takes over via the intercept. Next commit (M5.A.2) removes them.
95-objc-block-noop continues to pass; IR shows `__block_invoke_v`
(the synthesised name) replacing the hand-rolled
`__block_invoke_void` at the cast site. 189/189 example tests
pass; chess on iOS-sim green.
|
||
|
|
fcbd7a4235 |
ffi M4.B dealloc: release strong/copy property ivars + destroyWeak weak
emitObjcDefinedClassDeallocImp now walks the class's #property fields
BEFORE freeing the state struct. For each:
- assign → no-op (primitives, no ARC traffic).
- strong → val = load field; objc_release(val).
- copy → same as strong (the stored value is a +1 retained copy
produced by the setter's [val copy]; we release it here).
- weak → objc_destroyWeak(&field) — unregisters the slot from
libobjc's side-table so the runtime stops tracking it.
Order matters: property releases happen BEFORE freeing the state
struct (which would invalidate the pointers we need to read), which
happens BEFORE [super dealloc] (which eventually frees the Obj-C
instance's own memory). The full sequence is now:
%state = object_getIvar(self, __sx_state_ivar)
// M4.B (this commit):
for each strong/copy property P:
val = load struct_gep(state, P.idx); objc_release(val)
for each weak property P:
objc_destroyWeak(struct_gep(state, P.idx))
// M4.0c (already shipped):
allocator = load struct_gep(state, 0)
allocator.dealloc(state)
object_setIvar(self, ivar, null)
// M1.2 A.6:
[super dealloc] // → objc_msgSendSuper2
ffi-objc-arc-02-strong-property now passes: child held by parent's
strong property gets released when parent deallocates, refcount → 0,
child deallocates, both states freed via tracker. Balanced 2/2.
189/189 example tests pass; chess on iOS-sim green. M4 complete.
|
||
|
|
c88a293cf4 |
ffi M4.B getter: weak property reads through objc_loadWeakRetained
emitObjcDefinedPropertyGetter dispatches on objcPropertyKind. The strong/copy/assign paths keep their bare load. The weak path: retained = objc_loadWeakRetained(field_addr) autoreleased = objc_autorelease(retained) return autoreleased `objc_loadWeakRetained` does the race-safe upgrade via libobjc's side-table: if the target has deinitialized (or is mid-dealloc on another thread), returns null; otherwise returns the target with refcount bumped (+1 retained, transferred to caller). `objc_autorelease` drops the +1 into the current pool so the caller doesn't need to manually balance — matches Apple's auto-nil weak-getter contract. The bare-load weak path (still in place pre-M4.B-getter) worked for the single-threaded test scenario because the runtime nils the slot before the load happens. The load-retained version covers the multi-threaded "between load and use, target deinit's" race that silent bare-load can't. 189/189 example tests pass; chess on iOS-sim green. |
||
|
|
f4faef97dd |
ffi M4.B setter: emit ARC ops in sx-defined property setters
emitObjcDefinedPropertySetter now dispatches on objcPropertyKind to
emit the right runtime ops per Apple's ARC contract:
- assign → bare store (primitives, explicitly opted-out object slots).
- strong → load old; objc_retain(new); store new; objc_release(old).
Apple's runtime treats release(NULL) as a safe no-op, so
no explicit null-check on the old value.
- weak → objc_storeWeak(field_addr, val) — handles first-store
(init) and re-store (destroy + init) atomically. Registers
the slot with libobjc's side-table; the runtime auto-nils
it when the target deallocates.
- copy → [val copy] (sends `copy` selector — returns retained per
the NSCopying contract); load old; store the copied
instance; release old.
Side-effect on the weak path: even with the bare-load getter still in
place (loaded directly from the slot), weak reads work because Apple's
runtime side-table-nils the slot at target dealloc. The getter
improvement via objc_loadWeakRetained is the next commit and is
needed for race-safe reads (between load and use, the target could
deinit on another thread); for the single-threaded test scenarios
the bare load is sufficient.
ffi-objc-arc-02-strong-property advances from "child dealloc'd at
midpoint" to "unbalanced; alloc=2 dealloc=1" — strong setter now
retains, but the M4.B-dealloc cleanup hasn't landed so the child
held by the property isn't released when the parent deallocates.
Final commit (M4.B dealloc) closes the loop.
ffi-objc-arc-03-weak-property turns fully green: storeWeak +
auto-nil side-table do the work.
189/189 example tests pass; chess on iOS-sim green.
|
||
|
|
5c1d00a877 |
ffi M4.B helpers: objcPropertyKind + ARC runtime decls + xfail tests
Three pieces, no behavior change yet:
1. `ObjcPropertyKind` enum (strong/weak/copy/assign) + `objcPropertyKind`
helper in lower.zig. Reads `field.property_modifiers`, applies the
default rule (`*<ObjC-class>` → strong; primitives → assign), and
emits loud diagnostics for the silent-error budget:
- unknown modifier name (typo) → "expected one of: strong, weak, copy, ..."
- conflicting modifiers (e.g. `strong,weak`) → "mutually exclusive"
- `weak` on non-object slot → "requires a pointer-to-Obj-C-class type"
- `copy` on non-object slot → same
- `strong` (default or explicit) on `*void` → "ambiguous: specify
#property(strong|weak|copy|assign) explicitly"
Called from `emitObjcDefinedClassPropertyImps` for validation; the
returned kind isn't wired into setter/getter/dealloc yet — that's
the next three commits.
2. `ensureArcRuntimeDecls` lazily declares libobjc's ARC helpers:
objc_retain, objc_release, objc_storeWeak, objc_loadWeakRetained,
objc_initWeak, objc_destroyWeak. Uses the existing
`ensureCRuntimeDecl` pattern; idempotent.
3. Fix existing NSObject method names in std/objc.sx — `isEqual_`,
`isKindOfClass_`, `respondsToSelector_` had trailing underscores
that the selector mangling turned into double-colon selectors
(`isEqual::`). Removed the trailing underscore so the selectors
come out as `isEqual:`, `isKindOfClass:`, `respondsToSelector:`
as Apple's runtime expects.
4. Two xfail regression tests:
- ffi-objc-arc-02-strong-property: assigns child to parent's strong
property, releases the original child reference. Midpoint check:
child's dealloc should NOT have fired (strong setter retained).
Pre-M4.B-setter: child dealloc fires immediately → "FAIL: child
dealloc'd at midpoint" snapshot. Exit code 1.
- ffi-objc-arc-03-weak-property: assigns target to holder's weak
property, releases target. Reads holder.target → should be null
(auto-niled). Pre-M4.B-getter/setter: reads stale pointer →
"FAIL: weak property didn't auto-nil" snapshot.
These will turn green as M4.B setter (commit 2), getter (commit 3),
and dealloc-cleanup (commit 4) land. Each subsequent commit updates
the snapshot to reflect the now-passing output.
189/189 example tests pass; chess on iOS-sim green.
|
||
|
|
92ac51445d |
ffi M4.0c: -dealloc frees state through captured __sx_allocator
The synthesized -dealloc IMP now loads `state->__sx_allocator` (the
slot captured at +alloc time by M4.0a + M4.0b) and dispatches
`allocator.dealloc(state)` through the inline-protocol fn-ptr at
slot 2. Old behaviour was `free(state)` — went straight to libc,
ignoring whatever allocator the instance was constructed with.
After this commit, the per-instance allocator design from M1.2 A.5
is finally end-to-end correct:
push Context.{ allocator = arena } {
f := SxFoo.alloc(); ← arena.alloc(STATE_SIZE) + capture
// ... use f ...
}
// refcount → 0 ⇒ -dealloc:
// load state->__sx_allocator = arena
// arena.dealloc(state) ← same allocator round-trips
TrackingAllocator now sees the alloc/dealloc pair; the deferred M1.2
A.5 work is done. Closes the loop on M4.0.
The dealloc IMP passes `__sx_default_context` as the implicit __sx_ctx
when invoking the dealloc fn-ptr — the IMP itself has no caller-side
ctx (it's called by Apple's runtime at refcount-zero), and the
default GPA is the right baseline for any nested allocations the
dealloc body might perform.
Each compiler-internal lookup that "can't fail" (Context type,
__sx_default_context global) emits a loud diagnostic instead of
silent fall-through, per the silent-error budget.
184/184 example tests pass; chess on iOS-sim green.
|
||
|
|
2bbd63d929 |
ffi M4.0b: thread context.allocator through sx-defined +alloc
Two converging paths now allocate the state struct via the protocol's
allocator instead of raw malloc:
(1) sx-side `Cls.alloc()`: compiler intercepts in `lowerObjcStaticCall`
when the receiver is a sx-defined `#objc_class` and the method is
the niladic `alloc`. Emits the inline alloc-and-init sequence
using the caller's `current_ctx_ref` as the context — so
`push Context.{ allocator = my_arena } { let f := SxFoo.alloc(); }`
honors `my_arena` end-to-end. The msgSend dispatch is bypassed
entirely for this case.
(2) Obj-C-runtime `[Cls alloc]` (Info.plist principal class, NSCoder,
UIKit reflection): the synthesized `+alloc` IMP shim reads
`__sx_default_context.allocator` and calls into the same shared
helper. The IMP has `has_implicit_ctx = false` and runs with no
caller-side context — the default GPA is the right policy choice
for "everything Apple's runtime instantiates".
Shared helper `emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr)`
does the work: `class_createInstance` → `ctx.allocator.alloc(STATE_SIZE)`
via the inline-protocol fn-ptr → memset 0 → store allocator at
state[0] (the M4.0a slot, captured for -dealloc's later use) →
`object_setIvar(instance, __sx_state_ivar, state)`. Loud failures
on missing globals via the diagnostics system.
The sx-side interception must explicitly bitcast the
`class_createInstance` result from `*void` to the method's declared
return type (`*<Cls>` or `?*<Cls>`). lowerVarDecl reads the Ref's IR
type when no type annotation is present, and coerceToType is a
no-op for ptr→ptr — without the bitcast, `let f := SxFoo.alloc();`
binds `f` at `*void` and downstream `f.class` / `f.method()` fails
to find anything.
-dealloc still uses `free(state)` (M4.0c rewrites it). 184/184 tests
pass; chess on iOS-sim green.
|
||
|
|
8d7164f45f |
ffi M4.0a: prepend __sx_allocator to sx-defined-class state struct
State struct for an sx-defined `#objc_class` now leads with an Allocator field at index 0 — captured at +alloc time, read by -dealloc to free the state through the same allocator. User fields shift to index 1+; the existing by-name lookups in emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer naturally resolve them at the new indices. This step is the layout change only; the +alloc IMP still mallocs (M4.0b will rewrite it to thread context.allocator through), and -dealloc still uses free() (M4.0c). The field is allocated but uninitialised; nobody reads it yet. Storage type comes from `Context.fields[0].ty` via the new `objcStateAllocatorType` helper — same Allocator value-shape the implicit context machinery has used all along. If Context isn't registered (early-init paths), the helper falls back to omitting the field rather than synthesising a half-broken layout. IR snapshot for 142-objc-class-method-lowering updated to reflect the new struct shape and the +24-byte state allocation. Chess on iOS-sim green; 184/184 example tests pass. |
||
|
|
a923b6f6f0 |
ffi fix: route foreign-class UFCS arg target_types through extends chain
For UFCS dispatch on foreign-class receivers (`#foreign #objc_class` aliases), `resolveCallParamTypes` was returning an empty slice — both `resolveFuncByName(qualified)` and `fn_ast_map.get(qualified)` miss for `#foreign` methods (they live in `foreign_class_map`, not the regular fn maps). With `param_types` empty, the per-arg `target_type` assignment in `lowerCall` was skipped, leaving `self.target_type` as whatever it held on entry — usually the enclosing function's return type. Inside a `-> BOOL` method, `xx ptr` then lowered with target type `i8`: `ptrtoint ptr to i64` → `trunc i64 to i8`, sending the low byte of the pointer through. Symptom: chess on iOS-sim crashed in `-[NSNotificationCenter addObserver:selector:name:object:]` with `observer = 0xC0` (low byte of the SxAppDelegate receiver) when the AppDelegate method's first param was renamed to anything other than `self`. The original session diagnosed it as a `self`-vs-`this` hardcoding in `lower.zig`, but those hardcoded `"self"` strings are all on compiler-synthesized parameters (init scopes, JNI stubs, property IMPs, dealloc IMPs) — not the user-facing #objc_class body params. The bug was in arg-type resolution. Fix walks `foreign_class_map` + `findForeignMethodInChain` to recover the declared param types (skipping the implicit `*Self` for instance methods). Regression test `examples/issue-0044.sx` exercises the BOOL-return + foreign-class arg shape; pre-fix the receiver round-trip prints WRONG, post-fix it prints ok. |
||
|
|
f75923af00 |
uikit: type UIKitPlatform fields properly + handle optional in Obj-C encoding
The UIKitPlatform struct had a string of '*void = null; // UIWindow*' fields — the type lived in a comment, every callsite had to 'xx'-cast back to the real type. Migrated to the real foreign-class pointer types now that M3 declared all the relevant '#objc_class' aliases: window: ?*UIWindow root_vc: ?*UIViewController gl_view: ?*UIView (SxGLView OR SxMetalView — both extend UIView) gl_layer: ?*CALayer (CAEAGLLayer OR CAMetalLayer) gl_ctx: ?*EAGLContext display_link: ?*CADisplayLink Each field is wrapped in '?' since the platform may not have set it yet (gl_ctx is null in metal mode, display_link is null before the first frame, etc.). SxSceneDelegate's window getter/setter now take/return '?*UIWindow' instead of '*void' so calling code doesn't need an xx-cast. Required fix in objcTypeEncodingFromSignature: '?T' (optional) was bailing with 'type kind not yet supported'. Apple's runtime treats nullability as 'pointer may be null' — the wire encoding is the same as T. Recursive unwrap handles ?*UIView → '@', ?*CADisplayLink → '@', etc. Chess on iOS-sim: board renders, full pipeline intact. 183 tests + zig build test green. |
||
|
|
66f84f67b8 |
ffi M3.1 + M1.2 A.3 refactor: self=Obj-C id, self.field via ivar; SxAppDelegate migrated
Two coupled changes that unblock the uikit_register_classes
migration:
1) M1.2 A.3 — body's 'self' is the Obj-C id (opaque), NOT the
state struct. Matches Apple's ObjC semantics where 'self' IS
the object. Cocoa idiom 'xx self → id' works at runtime calls
(addObserver:, etc.); previously the trampoline replaced
'self' with the state-struct pointer, breaking any runtime
call that expected an id.
'*Self' substitution in resolveTypeWithBindings now points at
foreignClassStructType(fcd) — the opaque class stub — instead
of objcDefinedStateStructType(fcd).
'self.field' access on a sx-defined class instance field is
rewritten by lowerFieldAccess to go through the __sx_state
ivar:
state = object_getIvar(self, load(__<Cls>_state_ivar))
val = struct_gep(state, field_idx) → load
Both read (lowerFieldAccess) and write (lowerAssignment) take
this path. Compound ops (+=, -=, etc.) are supported via
storeOrCompound. The lookup is filtered: skip property fields
(those still go through the M2.2 msgSend getter/setter
dispatch) and foreign classes (no state).
New helpers in lower.zig:
- lookupObjcDefinedStateFieldOnPointer — match check.
- lowerObjcDefinedStateForObj — emit the object_getIvar +
ivar-global-load idiom (shared between read + write paths).
- lowerObjcDefinedStateFieldRead — the load path.
Also moved the @llvm.global_ctors registration out of the
sx-defined class-pair init constructor — global_ctors fires
DURING dyld's framework load, before UIKit registers its Obj-C
classes. objc_getClass("UIResponder") returned null, super
was null, objc_registerClassPair crashed. main's entry block
is post-framework-load but pre-user-code — exactly the right
window. New helper injectCtorIntoMain.
2) M3.1 — SxAppDelegate migrated to declarative #objc_class.
uikit_register_classes' hand-rolled objc_allocateClassPair +
class_addMethod for SxAppDelegate is gone; the compiler
synthesises the class at module init. The method bodies
forward to the existing legacy IMP free functions
(uikit_did_finish_launching, uikit_keyboard_will_change_frame)
so we don't have to inline 70+ lines of keyboard-frame logic
right now.
Also adds UIResponder foreign-class declaration and chains
UIView / UITextField to it via #extends UIResponder so the
methods that previously lived on UITextField directly
(becomeFirstResponder etc.) move to their proper home.
Chess on iOS-sim: board renders, full state intact. 183 example
tests + zig build test green.
|
||
|
|
ea32f8a27a |
ffi M2.3: #extends method-resolution chaining + Obj-C parent resolution
When 'obj.method()' is called on a foreign-class pointer and the
method isn't declared on the receiver's class, the compiler walks
the '#extends' chain to find an ancestor that declared it.
Property lookup (M2.2) flows through the same chain walker.
ParentX :: #foreign #objc_class("...") { foo :: ... }
ChildX :: #foreign #objc_class("...") { #extends ParentX; }
child.foo() // now resolves — was 'no method foo on ChildX'
Two new helpers in lower.zig:
- findForeignMethodInChain(fcd, name) walks the cache via
fcd.members[i].extends → foreign_class_map[parent] → ...
Depth-capped at 16 to break accidental cycles.
- findForeignPropertyInChain(fcd, name) — same shape for fields.
ALSO fixes a latent class-hierarchy bug uncovered while testing
M2.3: emit_llvm was passing the sx alias name to
objc_allocateClassPair(super, ...) rather than the actual Obj-C
runtime class name. For 'SxThing :: #objc_class(...) { #extends
NSObjectBase; }' where 'NSObjectBase' is aliased to "NSObject",
emit_llvm produced 'objc_getClass("NSObjectBase")' → NULL →
'objc_allocateClassPair(NULL, ...)' → SxThing's super-class link
was broken → '[sx_thing hash]' bypassed NSObject and crashed in
the forwarding machinery.
Fix: ObjcDefinedClassEntry gains a 'parent_objc_name' field
pre-resolved by lower.zig's 'resolveObjcParentName' through
foreign_class_map (which has the alias → foreign_path mapping).
emit_llvm just reads the resolved name from the entry.
153-objc-extends-chain.sx exercises both fixes:
1-level: SxThing → NSObject — t.hash() walks one #extends.
2-level: SxLeaf → SxMiddle → NSObject — chained #extends.
Both return real NSObject.hash values from libobjc.
183 example tests pass (+1). zig build test green.
|
||
|
|
239e7df27c |
ffi M2.2 (sx-defined): property getter/setter IMPs
Properties on sx-defined #objc_class declarations now synthesize
getter (always) and setter (unless 'readonly') IMPs that GEP into
the hidden state struct and load / store the corresponding field.
The state struct already holds every user-declared field
(objcDefinedStateStructType), so no new layout work — the IMPs
just dispatch a struct_gep + load/store through the __sx_state
ivar.
For each '#property' field on a sx-defined class:
Getter '__<Cls>_<field>_imp(self, _cmd) -> T':
state = object_getIvar(self, load(__<Cls>_state_ivar))
return state.<field>
Setter '__<Cls>_set<Field>_imp(self, _cmd, val) -> void':
state = object_getIvar(self, load(__<Cls>_state_ivar))
state.<field> = val
Both IMPs land in the cache's methods slice (mirroring the
method-IMP wiring from M1.2 A.4b.iii) so emit_llvm's
class_addMethod loop registers them on the class without
special-casing. Selector mangling:
getter: <field> (e.g. 'width')
setter: set<Field>: (e.g. 'setWidth:')
Type encoding derived from the field's resolved IR TypeId.
'readonly' (the only modifier honored in this slice) skips the
setter emission AND the corresponding method entry — so the
runtime reports the selector as absent. Other modifiers
(strong, weak, copy, assign) parse fine but stay no-ops until
M4.2 wires up ARC ops in the setter body.
152-objc-property-sx-defined.sx round-trips on macOS:
b.width = 10; b.height = 7;
read back through getter IMPs.
area is readonly — class_getInstanceMethod(SxBox, sel(setArea:))
returns NULL, confirming the setter is absent.
182 example tests pass (+1). zig build test green.
|
||
|
|
95f13849af |
ffi M2.2 (first pass): #property directive on foreign-class fields
Adds:
field: T #property[(modifier, modifier, ...)];
inside #objc_class declarations. For FOREIGN classes (this slice),
'obj.field' and 'obj.field = x' lower as objc_msgSend dispatches —
no struct GEP, no per-field storage on the sx side. The receiver
is opaque and the Obj-C runtime owns the data.
Selector mangling (Apple convention):
getter: <fieldName> (e.g. 'count')
setter: set<FieldName>: (e.g. 'setBackgroundColor:')
So:
view.backgroundColor → [view backgroundColor]
view.backgroundColor = red → [view setBackgroundColor:red]
Plumbing:
- New token hash_property + lexer entry + LSP keyword classification.
- ForeignFieldDecl gains 'is_property' + 'property_modifiers' slice;
the parser captures both. Modifiers are recorded verbatim (strong,
weak, copy, readonly, getter("name"), ...) — semantic interpretation
lands with M4.2 ARC wiring.
- lowerFieldAccess: lookupObjcPropertyOnPointer() detects the case
before the auto-deref / struct-GEP path and dispatches via
lowerObjcPropertyGetter (objc_msg_send).
- lowerAssignment: same check on the field_access LHS routes to
lowerObjcPropertySetter (objc_msg_send with set<Field>:).
- inferExprType: 'obj.field' returns the property's declared type
so chained access / coerced assignment work.
151-objc-property-foreign.sx round-trips:
inst.tag → [inst tag] → reads g_probe_tag → 0
inst.tag = 42 → [inst setTag:42] → writes g_probe_tag
inst.tag = -7 → ditto
Final: 0 -> 42 -> -7 (real Obj-C runtime dispatch).
DEFERRED for M2.2 (later passes):
- Sx-defined property IMPs (synthesized getter/setter trampolines
reading/writing the state struct).
- Modifier-driven setter behavior: readonly (compile error on
write), copy (deep-copy), weak (objc_storeWeak), strong/assign
(Month 4.2 ARC ops).
- getter("name") / setter("name:") selector overrides.
181 example tests pass (+1). zig build test green.
|
||
|
|
c39c8e15eb |
ffi M2.1(b): class methods on sx-defined #objc_class
Bodied methods without a '*Self' first param (parser marks
is_static=true) are now registered as Obj-C CLASS methods on
the metaclass.
Each such method gets:
- A synthesized FnDecl + body lowering through the existing
M1.2 A.2 path.
- A C-ABI trampoline 'emitObjcDefinedClassStaticImp' — same
shape as the instance trampoline but skips the __sx_state
ivar read (no instance state) and passes only
'__sx_default_context' (plus user args) to the sx body.
- An entry in ObjcDefinedMethodEntry with 'is_class=true'.
emit_llvm's class-pair init constructor now computes the
metaclass once up-front (via object_getClass(cls)) and shares
it between the +alloc IMP registration (M1.2 A.5) and the
M2.1(b) class-method registrations. The per-method registration
loop picks the target via 'method.is_class ? metaclass : cls'.
149-objc-class-method-static-imp.sx end-to-end on macOS:
SxFoo :: #objc_class("SxFoo") {
answer :: () -> s32 { return 42; }
}
// [SxFoo answer] via objc_msgSend → 42
// class_getClassMethod(SxFoo, sel_answer) → non-null
Still TODO for M2.1: the (a) class-LEVEL constant form
'layerClass :: Class = CAEAGLLayer.class();' — needs parser
extension to recognize 'name :: Type = expr;' inside #objc_class
blocks, plus lazy-init-slot synthesis.
179 example tests pass (+1). zig build test green.
|
||
|
|
0ac5ba2ccd |
ffi M1.3: obj.class accessor on Obj-C-class pointers
Adds a special case to lowerFieldAccess: when the field is
literally 'class' and the receiver is a pointer to an Obj-C
(or Obj-C protocol) foreign-class struct, emit
'object_getClass(obj)' instead of falling through to struct GEP.
Returns 'Class' (the M1.1 first-pass alias for *void;
parameterized Class(T) covariance is deferred to M1.1.b).
f := SxFoo.alloc();
cls := f.class; // → object_getClass(f)
cls == objc_getClass("SxFoo".ptr); // ok
New helper isObjcClassPointer(ty) detects 'ptr -> struct in
foreign_class_map under .objc_class / .objc_protocol'. The
check fires BEFORE the auto-deref so the runtime call sees the
opaque Obj-C pointer rather than the load'd struct stub.
148-objc-self-class-accessor.sx exercises both shapes end-to-end
against the macOS runtime: sx-defined class (SxFoo) and foreign
class (NSObject). Round-trips against objc_getClass(name).
178 example tests pass. zig build test green.
This effectively closes Month 1 — M1.0, M1.1 (first pass), M1.2,
M1.3 all done. Remaining: M1.1.b (Class(T) covariance +
instancetype), then Month 2 (declarative sugar).
|
||
|
|
51277afadf |
ffi M1.2 A.7: open the dispatch gate — sx-defined class methods callable
Delete the bail at lower.zig:4407 that diagnosed sx-defined Obj-C
class dispatch as 'not yet supported'. Both foreign and
sx-defined '#objc_class' decls now flow through the same
'lowerObjcMethodCall' path — instance methods on sx-defined
classes dispatch via objc_msgSend, and the registered IMP
trampolines (M1.2 A.4b.iii) route to the sx bodies.
The runtime non-Obj-C branch (.swift_class / .swift_struct /
.swift_protocol) keeps its 'not yet supported' diagnostic;
M1.2 only addresses the Obj-C runtimes.
Constructor reorder in emit_llvm: emitObjcDefinedClassInit
runs BEFORE emitObjcClassInit. Otherwise the Phase 3.1
class-cache populator calls objc_getClass("SxFoo") before our
constructor registers the class — cache slot stored null and
'SxFoo.method()' dispatched against a null class pointer.
ffi-objc-defined-class-01-instance.sx (the integration test
from the plan) now runs the full lifecycle on macOS:
f := SxFoo.alloc() // synthesized +alloc IMP fires
f.bump() // dispatch → IMP trampoline → sx body
f.bump() // state persists across calls
f.bump()
f.get() // → 3
release_fn(f, sel_release) // synthesized -dealloc fires
The user declares 'alloc :: () -> *SxFoo;' bodyless to give the
synthesized +alloc IMP a typed contract at sx call sites —
same convention as foreign classes today.
M1.2 complete: A.0 A.1 A.2 A.3 A.4 A.4b.i A.4b.ii A.4b.iii
A.5 A.6 A.7. End-to-end class-synthesis foundation works.
177 example tests pass (+1 from the integration test). zig
build test green.
|
||
|
|
c107aa4e21 |
ffi M1.2 A.6: synthesized -dealloc IMP + [super dealloc] chain
For every sx-defined #objc_class, emit a C-callconv -dealloc IMP
that runs at refcount-zero. Frees the sx state struct, nils the
ivar, then chains to [super dealloc] so NSObject's runtime
cleanup (object_dispose, associated-object teardown, KVO, etc.)
runs as usual.
-dealloc IMP (self: id, _cmd: SEL) -> void
state = object_getIvar(self, load @__<Cls>_state_ivar)
free(state) // free(NULL) is safe
object_setIvar(self, ivar, NULL)
sup = alloca { receiver: *void, super_class: *void }
sup.receiver = self
sup.super_class = load @__<Cls>_class
sel_dealloc = sel_registerName("dealloc")
objc_msgSendSuper2(&sup, sel_dealloc)
return
Two new per-class globals:
- '__<Cls>_class' : *void — populated by emit_llvm's
class-pair init constructor with the freshly-allocated Class
pointer (after objc_registerClassPair).
- The existing '__<Cls>_state_ivar' is also consulted to find
the state struct.
The -dealloc IMP is registered on the class itself (instance
method) via class_addMethod with encoding 'v@:'. emit_llvm
ALSO stores cls_val into '__<Cls>_class' so the trampoline
can build the objc_super struct.
internStringConstantGlobal helper added to lower.zig — interns
C strings as [N:0]u8 globals with byte-level aggregate inits.
Used here for the 'dealloc' selector string.
147-objc-class-dealloc-roundtrip.sx verifies end-to-end on
macOS: alloc + release fires the IMP, and a second alloc/release
cycle proves runtime state isn't corrupted. class_getMethod-
Implementation confirms the IMP is registered.
176 example tests pass (+1). zig build test green.
Still gated: sx-side 'obj.method()' calls bail at lower.zig:4407
with the existing diagnostic. A.7 opens the gate — last sub-step
of M1.2.
|
||
|
|
a1736f3213 |
ffi M1.2 A.5: synthesized +alloc IMP + ensureCRuntimeDecl helper
For every sx-defined #objc_class, emit a C-callconv +alloc IMP
that the Obj-C runtime calls when '[Cls alloc]' fires (from sx
code, UIKit instantiation, Info.plist principal class, etc.):
+alloc IMP (cls: Class, _cmd: SEL) -> id
instance = class_createInstance(cls, 0)
state = malloc(STATE_SIZE)
memset(state, 0, STATE_SIZE)
object_setIvar(instance, load(@__<Cls>_state_ivar), state)
return instance
STATE_SIZE = max(typeSizeBytes(state struct), 1) — always at
least one byte so the ivar is never null after +alloc returns.
The IMP is registered on the METACLASS (class methods live there
— every Class object's isa points to the metaclass) in emit_llvm's
class-pair init constructor:
metaclass = object_getClass(cls)
sel_alloc = sel_registerName("alloc")
class_addMethod(metaclass, sel_alloc, alloc_imp, "@@:")
That override wins over NSObject's default +alloc; runtime
instantiations get the __sx_state ivar bound automatically.
Per-instance allocator binding (the plan's full design — store
the Allocator value in the state struct so -dealloc frees through
the same one) is deferred. libc malloc/free is fine for v1; we'll
upgrade once Month 4's autoreleasepool + ARC ops shake out.
REFACTOR: collapsed five duplicate 'get<Name>Fid' helpers and
their cache fields (object_getIvar, object_setIvar,
class_createInstance, malloc, memset) into a single
'ensureCRuntimeDecl(name, params, ret) -> FuncId'. The helper
checks for an existing decl by name first (avoids the
'class_createInstance.1' duplicate-symbol crash when stdlib's
'#foreign' decl is already in the module). One helper instead
of one-per-function = ~150 lines deleted.
object_getIvar / object_setIvar added to stdlib std/objc.sx
so user code can use them too (146 exercises object_getIvar
to verify __sx_state was bound to a non-null state pointer
after +alloc).
146-objc-class-alloc-roundtrip.sx end-to-end against macOS:
'[SxFoo alloc]' returns non-null AND object_getIvar(instance,
__sx_state) returns the state ptr. Real Obj-C runtime, no
mocks.
175 example tests pass (+1). zig build test green.
|
||
|
|
87572579b4 |
ffi M1.2 A.4b.iii: class_addMethod wires IMPs to the Obj-C runtime
For each instance method on a sx-defined '#objc_class', the
class-pair init constructor now:
sel = sel_registerName("selector_string")
imp = @__<Cls>_<method>_imp (M1.2 A.4b.ii)
class_addMethod(cls, sel, imp, "<encoding>")
before objc_registerClassPair. The IMP trampoline (A.4b.ii)
already bridges C-ABI -> sx body. With registration in place,
'objc_msgSend(obj, sel_bump)' now routes to the trampoline,
which reads __sx_state ivar and forwards to '@<Cls>.<method>'.
To get selector + type-encoding strings out of lower.zig and
into emit_llvm, ObjcDefinedClassEntry gains a 'methods' slice:
pub const ObjcDefinedMethodEntry = struct {
sel: []const u8, // mangled selector (M1.2 A.1's deriveObjcSelector)
encoding: []const u8, // type encoding (M1.2 A.1's objcTypeEncodingFromSignature)
imp_name: []const u8, // C-callconv trampoline symbol
};
registerObjcDefinedClassMethods populates this when it declares
each method's body function; Module.setObjcDefinedClassMethods
attaches the slice to the cache entry by name. Static (class-
side) methods are skipped — A.4b only covers instance methods;
class-method hooks like '+layerClass' land in M2.1.
emit_llvm reads entry.methods and emits class_addMethod inside
the per-class init block, before objc_registerClassPair (the
runtime locks the method list at register time on some SDK
versions).
145-objc-class-method-dispatch.sx verifies end-to-end:
class_getMethodImplementation(SxFoo, sel_registerName("bump"))
returns non-null after main starts. Both niladic ('bump') and
single-arg ('add:') selectors checked.
Still gated (A.7): sx-side 'obj.bump()' calls. The dispatch
gate at lower.zig:4407 hasn't opened — A.5 (+alloc) and A.6
(-dealloc) need to land first so the integration test
ffi-objc-defined-class-01-instance.sx (full state round-trip)
can exercise the full lifecycle.
174 example tests pass (+1 from 145). zig build test green.
|
||
|
|
c0b338eaa4 |
ffi M1.2 A.4b.ii: emit C-ABI IMP trampolines (dead code pending class_addMethod)
For each bodied instance method on a sx-defined #objc_class,
emit a C-callconv trampoline function '__<Cls>_<method>_imp':
void __SxFoo_bump_imp(ptr obj, ptr _cmd, ...user_args) {
ivar = load @__SxFoo_state_ivar
state = object_getIvar(obj, ivar)
call @SxFoo.bump(__sx_default_context, state, ...user_args)
ret
}
The trampoline bridges the Obj-C runtime's IMP calling convention
('id self, SEL _cmd, ...args' as C ABI) to the sx body's
default-callconv shape ('__sx_ctx ptr, state ptr, ...user_args').
Implicit context comes from '&__sx_default_context'; the body
keeps its sx-side personality intact and can use 'self.field'
through the substituted state-struct pointer (M1.2 A.2b + A.3).
New helpers in lower.zig:
- 'getObjcObjectGetIvarFid' lazily declares object_getIvar.
- 'emitObjcDefinedClassImps' + 'emitObjcDefinedClassImp' walk the
cache and synthesise each trampoline.
- 'lookupGlobalIdByName' for finding the per-class ivar handle
global. Linear scan — same N-is-small rationale as the other
Obj-C caches.
Dead code at this commit: the trampolines exist in the module
but no class_addMethod call registers them with the runtime.
'objc_msgSend(obj, sel_bump)' would still fall through to the
parent class (NSObject 'doesNotRecognizeSelector:') today.
A.4b.iii wires up class_addMethod in emit_llvm's class-pair-init
constructor — that's when the trampolines come alive.
142's IR snapshot refreshed to show the trampoline.
173 example tests pass. zig build test green.
|
||
|
|
c2178c062b |
ffi M1.2 A.4b.i: __sx_state ivar registration
Class-pair init constructor now registers a single hidden ivar on each sx-defined class: class_addIvar(cls, "__sx_state", 8, 3, "^v") before objc_registerClassPair. After the class is registered, the constructor calls class_getInstanceVariable to fetch the runtime Ivar handle and stores it in a per-class global '__<ClassName>_state_ivar : *void'. Trampolines (A.4b.ii) will read this global to 'object_getIvar' the state struct pointer. lower.zig declares the per-class global at scan time (declareObjcDefinedStateIvarGlobal) so emit_llvm finds it by name when populating. Encoding '^v' = void* (a generic pointer — the runtime treats it as opaque storage). log2 alignment = 3 for 8-byte pointer alignment on 64-bit. 144-objc-class-ivar-registration.sx exercises the round-trip: after main starts, class_getInstanceVariable(SxFoo, "__sx_state") returns non-null. Runs against the real Obj-C runtime on macOS. 142's IR snapshot refreshed to include the new constructor body (class_addIvar + class_getInstanceVariable + ivar-global store). 173 example tests pass (+1 from 144). zig build test green. |
||
|
|
659cdc2276 |
ffi M1.2 A.2c + A.3: eager body lowering + self.field via state struct
Adds Pass 4b 'lowerObjcDefinedClassMethods' to lowerRoot: after
scan, walk objc_defined_class_cache and force-lower each bodied
instance method. The Obj-C runtime invokes these via the IMP
pointers wired up in A.4 — no sx-side call path drives lazy
lowering, so we trigger it here. Mirrors the JNI eager-lower
pattern in Pass 5.
Bug fix: lazyLowerFunction has its OWN inline body-lowering
path (separate from lowerFunction) that re-resolves param types
at line 1025. It was running without current_foreign_class set,
so '*Self' fell through to the type_bridge fallback and got
interned as a 0-field struct named 'Self' — body's
'self.counter' GEP'd into '{}' and LLVM verification rejected.
Fix: set current_foreign_class at the top of lazyLowerFunction
via the same lookupObjcDefinedClassForMethod path lowerFunction
uses. Save+restore via defer.
A.3 ('self.field access via the ivar') falls out for free —
'*Self' resolves to '*__SxFooState' so 'self.counter' is a
plain struct field access. IR snapshot in
142-objc-class-method-lowering.ir shows the round-trip:
define internal void @SxFoo.bump(ptr, ptr self) {
%gep = getelementptr inbounds { i32 }, ptr %self, 0, 0
%v = load i32, ptr %gep
store i32 (%v + 1), ptr %gep
ret void
}
171 examples pass (+1 from 142); zig build test green.
Still gated: Obj-C runtime dispatch (A.7) — sx-side
'f.bump()' calls bail at lower.zig:4407 with the existing
diagnostic. IMP-trampoline emission (the C-ABI shim that bridges
'objc_msgSend' → this body) lands in A.4 alongside class-pair
init.
|
||
|
|
ae1072d415 |
ffi M1.2 A.2b: register sx-defined #objc_class methods + *Self substitution
Bodied instance methods on a sx-defined '#objc_class("Cls") { ... }'
declaration are now registered in fn_ast_map under '<Cls>.<method>'
and declared in the IR with their *Self params substituted to
the hidden state-struct type (M1.2 A.2a).
registerObjcDefinedClassMethods walks the foreign_class_decl's
members, synthesizes an FnDecl from each ForeignMethodDecl (zipping
params + param_names), and feeds it through declareFunction with
current_foreign_class temporarily pinned so resolveTypeWithBindings
substitutes Self → __SxFooState.
resolveTypeWithBindings now treats type_expr 'Self' as a contextual
alias: when current_foreign_class points to a sx-defined Obj-C
class, the substitution returns objcDefinedStateStructType(fcd).
Other Self contexts (protocols, JNI super, foreign-class member
type resolution) are untouched — the check filters on (!is_foreign
and runtime == .objc_class).
lowerFunction also sets current_foreign_class for the duration of
the body lowering when the name is qualified <Cls>.<method> and
Cls is in objc_defined_class_cache. Save+restore via defer so
nested calls round-trip cleanly.
Verification (manual): 'sx ir' on an sx-defined class shows
'declare void @SxFoo.bump(ptr, ptr)' — two args = implicit
__sx_ctx + the state-struct pointer (correct *Self substitution).
Body emission happens lazily; A.2c will trigger it eagerly so
the IMP trampoline (A.4) can reference it.
170 example tests + zig build test green.
|
||
|
|
7b98b3ae78 |
ffi M1.2 A.2a: objcDefinedStateStructType helper
Builds (and interns) the hidden sx-state struct type for an
sx-defined '#objc_class'. Layout:
__<ClassName>State {
user_field_0,
user_field_1,
...
}
This struct is what the runtime's '__sx_state' ivar points at —
separate from the Obj-C object itself, which stays opaque. The
sx method bodies will operate on '*__SxFooState' (after '*Self'
substitution in A.2b) so 'self.field' resolves to a plain struct
field access — A.3's 'free if types align' premise.
M1.2 A.5 will prepend '__sx_allocator: Allocator' so dealloc can
free through the per-instance allocator. Field-by-name access
stays correct across the future repositioning.
Methods / '#extends' / '#implements' members are ignored — only
'.field' contributes. Three unit tests pin: typical-field case,
empty-class case, mixed-member case.
Dead code at this commit — helper isn't called yet. A.2b (body
lowering with '*Self' substitution) wires it in. 170 example
tests + zig build test green.
|
||
|
|
6cc016cd4f |
ffi M1.2 A.1: objcTypeEncodingFromSignature helper + encoding table
Derives Apple's runtime type-encoding string from an IR method signature. Called by class_addMethod(cls, sel, imp, types) when M1.2 A.4+ synthesise IMPs for sx-defined classes. Layout: <ret> @ : <param0> <param1> ... — @ is the receiver, : is _cmd. Caller passes user-declared params AFTER stripping 'self: *Self'. Encoding table: v=void B=bool c=s8/BOOL s=s16 i=s32 q=s64 C=u8 S=u16 I=u32 Q=u64 f=f32 d=f64 @=foreign Obj-C class ptr #=Class :=SEL *=[*]u8 (C string) ^v=any other ptr bool (sx i1) maps to 'B' (C99 _Bool); s8 to 'c' (Apple's BOOL). Foreign-class pointers detected via foreign_class_map lookup on the pointee struct name. Other pointers fall to ^v — encoding is metadata, not ABI, so conservative is safe. Struct / slice / closure / etc. BAIL via diagnostic (ObjcEncodingUnsupported) rather than silently mis-encoding, per CLAUDE.md rejected-patterns rule. Future passes will widen the table as new shapes show up in real IMPs. Dead code at this commit — helper isn't called yet. Three unit tests in src/ir/lower.test.zig pin the primitive / pointer / Obj-C-class-pointer encodings before A.2 wires the helper in. 170 example tests + zig build test green. |
||
|
|
61a2593020 |
ffi M1.2 A.0: objc_defined_class_cache + scan-pass registration
Adds an insertion-ordered cache on Module for sx-defined Obj-C
classes — every '#objc_class("Cls") { ... }' declaration WITHOUT
'#foreign'. registerForeignClassDecl appends the entry alongside
its existing foreign_class_map insert; lookup helper available
via Module.lookupObjcDefinedClass.
ObjcDefinedClassEntry { name, *const ast.ForeignClassDecl }
The pointer back into the AST lets later passes (M1.2 A.1+) walk
'members' for fields / methods / '#extends' / '#implements'
without duplicating that data on the entry. Insertion order
matters because class-pair init constructors (A.4) must register
parent classes before children — 'objc_allocateClassPair(super,
...)' resolves super by lookup.
Infrastructure only — no observable behavior change. The cache
is populated but not yet read; A.1+ start pulling from it. 170
example tests + zig build test green.
|
||
|
|
2b717d9b38 |
ffi: resolve foreign-class member types through Self substitution (issue-0043)
`inferExprType` for a chained call `Cls.static().instance(...)` never
looked the inner call's foreign-class declaration up, so the outer
dispatch saw a `.s64` receiver, the `foreign_class_map.get(...)` lookup
missed, and lowering emitted `error: unresolved 'method'`. The macOS
target appeared to work because `inline if OS == .ios { ... }` strips
the gated body before lowering — eliding every call that would have
exercised the broken path.
The "lazy-lower" framing in the original issue file was a red herring.
Fix in `src/ir/lower.zig`:
1. `inferExprType` for `.call` with `.field_access` callee now checks
`foreign_class_map` for both shapes — `Cls.static_method(args)` (object
identifier matches a foreign-class alias, look up static members) and
`inst.instance_method(args)` (receiver is a pointer to a foreign-class
struct, look up non-static members).
2. New helpers `resolveForeignMethodReturnType` and
`resolveForeignClassMemberType` substitute `*Self` / `Self` to the
foreign-class struct so a `*Self` return doesn't synthesize a phantom
`Self`-named struct that future dispatches can't resolve.
3. The Obj-C lowering paths (`lowerObjcMethodCall`, `lowerObjcStaticCall`)
route through the same helper for `ret_ty` so the IR Ref's type matches
what `inferExprType` reports.
Regression test at `examples/138-foreign-class-chained-dispatch.sx`
exercises NSObject's `+alloc` / `-init` chain in both shapes —
`*NSObject` return then `*Self` return, and `*Self` then `*Self`. Runs
on the host (macOS) for live exercise; non-macOS hosts fall through to
a stub matching the expected output.
This unblocks Phase 3.2 C4/C5 — the `UIWindow.alloc().initWithWindowScene(scene)`
pattern that surfaced the bug is the cluster's bread-and-butter shape.
167/167 example tests; chess builds clean on macOS, iOS-sim, Android.
|
||
|
|
572ab12142 |
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.
|
||
|
|
8406cc1fed |
ffi 3.1: Cls.static_method(args) lowers to objc_msg_send on the class object
Implementation half of the Phase 3.1 cadence step. `lowerForeignStaticCall` for `#objc_class` / `#objc_protocol` runtimes no longer bails; it routes through a new `lowerObjcStaticCall` helper that loads the class object from a module-scoped cached slot (populated once per module via `objc_getClass`) and dispatches `objc_msg_send` with the same selector-mangling as Phase 3.0's instance dispatch. Three pieces: 1. `Module.objc_class_cache` — parallel to `objc_selector_cache`, insertion-ordered list of (class_name, slot_GlobalId) so the constructor that calls `objc_getClass` per slot at module load is deterministic. `lookupObjcClass` / `appendObjcClass` accessors. 2. `internObjcClassObject` in lower.zig — get-or-create a `OBJC_CLASSLIST_REFERENCES_<Cls>` global pointer; matches clang's naming convention. `lowerObjcStaticCall` reuses `deriveObjcSelector` from 3.0 for the selector, loads the class slot, and emits `objc_msg_send(class_obj, sel, args)`. 3. `emitObjcClassInit` in emit_llvm.zig — companion to `emitObjcSelectorInit`. Walks `objc_class_cache`, synthesizes a constructor `__sx_objc_class_init` that calls `objc_getClass(name)` per slot, registers in `@llvm.global_ctors` for AOT (extending the existing array if the selector init already created it), and injects a direct call into main's prelude after any prior init calls so the ORC JIT path runs it too. Surface form is `.` (`NSObject.class()`) matching JNI's `Alias.new(...)` convention rather than the plan's notional `::` — avoids extending the parser for a new postfix operator with no other use case. Test `examples/ffi-objc-dsl-05-static.sx` exercises NSObject's `+class` and `+description` class methods via the new syntax, asserts both return non-null. NSObject is always available at module-load, unlike runtime-created test classes that wouldn't exist yet when the class-init constructor runs. 164/164 tests; chess builds + runs clean on all three platforms. |
||
|
|
53fe73acda |
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).
|
||
|
|
071352e655 |
mem: remove resolveType(null) → .s64 silent fallback
CLAUDE.md REJECTED PATTERNS forbids silent default returns where the
"reasonable-looking" value happens to match one common case (s64 = 8
bytes = pointer-sized on the host) and is silently wrong everywhere
else. `resolveType(null) → .s64` was exactly this shape: a top-level
`g_pi := 3.14;` was silently typed as `s64`, producing a wrong-typed
slot and the wrong runtime value.
`resolveType` now takes a non-optional `*const Node`. Twelve callers
were classified:
- Six were already guarded by `if (x.type_annotation != null)` blocks
— the null branch was unreachable. Cleaned up to optional-payload
syntax (`if (cd.type_annotation) |ta|`) so the always-non-null path
is obvious from the type.
- Two (`#objc_call` / `#jni_call` return types) pass `FfiIntrinsicCall.
return_type`, which is `*Node` (not optional) in the AST — the
silent fallback couldn't be reached there either.
- One (top-level `var_decl` at lower.zig:630) DID legitimately receive
null when the user omitted both annotation and initializer typing.
Now mirrors `lowerVarDecl`'s local-scope behavior: explicit
annotation → resolveType; no annotation → `inferExprType` from the
initializer; neither → diagnose with a real error message.
- One (`lowerComptimeGlobal`, fixed in commit
|