Commit Graph

219 Commits

Author SHA1 Message Date
agra
9e76a83f69 ffi block-string-arg ABI mismatch — expected-failing lock-in
Generic `Into(Block) for Closure(string) -> void` (step 5.2) emits
a trampoline whose `callconv(.c)` param type collapses through
`abiCoerceParamType`'s `string → ptr` heuristic — the libc
"char *" convention. The caller side (typed fn-pointer cast +
indirect call through `b.invoke`) keeps the full `{ptr, i64}`
slice. Result on AArch64: caller passes 16 bytes in x0+x1,
trampoline reads 8 bytes from x0 only, the slice len is lost or
mis-tracked, and the trampoline's `memcpy` from the half-formed
string segfaults.

`examples/188-block-string-arg.sx` pins the post-fix behaviour
("got: <hello>"). Today's run segfaults inside the trampoline's
first read. The next commit splits `abiCoerceParamType` into a
foreign-only path (extern decls keep the libc collapse) and a
preserve-slice path (sx-internal `callconv(.c)`).
2026-05-28 12:24:49 +03:00
agra
82f291e5af ffi M5.A.next.4B.A: compile_error intrinsic — expected-failing lock-in
`compile_error(msg)` raises a build-time diagnostic at the call site
with `msg` as the error text. The arg must be a string literal —
runtime expressions can't be reported as compile errors. Used by
builder fns to reject malformed pack shapes / arg combinations
cleanly instead of silently emitting wrong code.

Today: `unresolved 'compile_error'`. Expected (post-fix): focused
diagnostic with the literal message at the call site's span. The
next commit adds the lowering arm.
2026-05-28 12:17:49 +03:00
agra
280c12c630 issues: promote 6 fixed bug repros to focused regression tests
All six produce their target outputs cleanly today; renamed out of
the `issue-*` namespace per CLAUDE.md "Resolving an open issue":

| Old                  | New                                       |
|----------------------|-------------------------------------------|
| issue-0032           | 181-impl-duplicate-same-file              |
| issue-0041           | 182-compound-type-in-expression           |
| issue-0042           | 183-type-alias-size-align                 |
| issue-0044           | 184-objc-defined-class-method-self        |
| issue-0045           | 185-pack-fn-comptime-return               |
| issue-0046           | 186-nested-comptime-return                |

Comment headers tightened to feature-focused (drop the issue-NNNN
provenance — that's in git history now). Missing expected `.txt` /
`.exit` files captured for 0041 + 0042 (they were untracked because
the bugs were fixed silently in adjacent work).

`examples/issue-*` after this commit: just `issue-0030.sx` — a
feature request (`extern G : T;` cross-file globals) that's never
been implemented. Staying in the issue namespace as a parked
proposal until the feature lands or gets formally rejected.

220/220 example tests + `zig build test` green.
2026-05-28 12:14:52 +03:00
agra
da6f318a3f issues 0033 + 0034: rename repros to focused regression tests
Both repros emit their target diagnostics cleanly today (verified
2026-05-28 against HEAD):

- `issue-0033` → "no visible xx conversion from 's64' to 'Wrap'
  — impl exists in another module but is not imported". Catches
  the case where an `impl Into(X) for Y` is registered globally
  via one module's import chain but is NOT transitively imported
  by the file containing the `xx` site.
- `issue-0034` → "duplicate xx conversion from 's64' to 'Wrap':
  impls in <a> and <b>". Catches two impls covering the same
  (Source, Target) pair both reachable from a single `xx` site.

Renamed to focused feature names:

- `issue-0033*` → `179-impl-visibility*` (4 files: main + impl +
  types + user).
- `issue-0034*` → `180-impl-duplicate*` (4 files: main + impl-a +
  impl-b + types).

Path references inside the files updated. Comment headers tightened
to feature-focused (drop issue-NNNN provenance — that's in git
history now). Expected `.txt` / `.exit` files captured against the
full diagnostic text and exit code 1.

The `issue-*` namespace in `examples/` now shrinks to the literal
list of UNRESOLVED bug repros. 218/218.
2026-05-28 12:08:54 +03:00
agra
11eef8a6b1 ffi step 6: print / format migrate to ..\$args (comptime per-position pack)
`format` and `print` move from `..args: []Any` to `..$args`. The
pack-fn machinery monomorphises each call shape, so the
build_format-emitted body's `any_to_string(args[i])` substitutes
to the i-th concrete-typed call arg via packArgNodeAt — no more
runtime Any-boxing for static args. The Any boxing path still
fires for arg positions whose types collapse to `.any` (already
Any-typed inputs).

Net effect:
- Calls with statically-typed args produce per-shape monos
  (`print__ct_<fmt_hash>__pack_s64_string_bool` etc). The mono
  cache key now reflects both the format string AND the arg
  types, so different shapes get distinct emit paths.
- Compile-time arity errors are now possible (callers passing
  the wrong number of args mismatch the mono's positional
  binding instead of silently mis-boxing).
- Optionals flow through the new `case optional:` arm in
  `any_to_string` (commit ce77867); the variadic auto-unwrap
  in `packVariadicCallArgs` stays as a fast-path but is no
  longer load-bearing.

IR snapshots regenerated for 13 tests where the print/format
mono shape changed the string-constant pool: 142, the ffi-jni
test cluster, ffi-objc-call-03/06, ffi-objc-dsl-07. Test
08-types' undef-memory-read snapshot also shifted (the test
exercises `field = ---` reads from a print call's stack
neighbours; the new pack-mono lays out its stack frame
differently, so the previously-stale 1s now read as 0s — same
undefined behaviour, different garbage).

218/218 example tests + `zig build test` green.
2026-05-28 08:04:12 +03:00
agra
ce77867566 ffi any_to_string handles optionals — make-green
Closes the optional-through-Any gap that test 178 pinned.

Stdlib (`library/modules/std.sx`):
- New `optional_to_string :: (o: $T) -> string` returns `"null"`
  when the optional is None, otherwise recurses through
  `any_to_string` on the unwrapped inner value. Per-shape
  monomorphisation re-emits this for each concrete `?T`.
- `any_to_string` grows a `case optional:` arm that dispatches
  through `cast(type) val` (same shape as `case struct:` etc.).
  The cast picks up the dynamic optional type from the Any tag.

Compiler (`src/ir/lower.zig`):
- `resolveTypeCategoryTags` recognises "optional" as a dynamic
  category, scanning the TypeTable for `info == .optional`. The
  type-switch dispatch then routes any ?T tag into the optional
  arm.

IR snapshots regenerated where the optional addition shifted
constant pool / string numbering: 142, ffi-objc-call-06,
ffi-objc-dsl-07. 218/218 (test 178 included).

The variadic auto-unwrap in `packVariadicCallArgs` stays in
place — direct `print(opt)` calls still flow through it. The new
arm closes the gap for struct fields, slice elements, and any
other path that boxes an optional before stringifying.
2026-05-28 07:51:44 +03:00
agra
54e404bca1 ffi any_to_string optional dispatch — expected-failing lock-in
`examples/178-any-to-string-optional.sx` prints a struct whose
three fields are `?s64` / `?string` / `?bool`, in both Some and
None form. The struct-print path goes through `field_value(s, i)
-> Any` and then `any_to_string(Any)`. Today: `any_to_string`
has no `case optional:` arm and `resolveTypeCategoryTags` has no
"optional" category — every optional field falls through to the
`<?>` default. Expected output captures the working post-fix
form (`a: 42`, `b: hi`, `c: true` for Some; `null` across the
board for None).

The next commit adds `optional_to_string` + `case optional:` to
std and "optional" to `resolveTypeCategoryTags`. Variadic
auto-unwrap (`packVariadicCallArgs`) keeps printing direct
`print(opt)` calls correctly today; this fix closes the gap for
struct fields, slice elements, and anywhere else an optional
flows through Any.
2026-05-28 07:50:59 +03:00
agra
f5342e9fcc ffi M5.A.next.5.2.A: generic Into(Block) impl — xfail lock-in
`examples/177-generic-into-block.sx` exercises a closure shape
(`Closure(s64, s64) -> void`) that stdlib's hand-rolled
`Into(Block)` impls don't cover. Today: the focused diagnostic
"no `Into(Block) for cl_s64_s64__void` 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" fires at the `xx cl : Block` site.

The next commit adds the generic
`impl Into(Block) for Closure(..$args) -> $R` to
`library/modules/std/objc_block.sx` (wiring `#insert
build_block_convert($args, $R)` from step 5.1.B) plus the
lowering plumbing needed to make pack + single-type `$` refs
work inside the impl's monomorphisation. The test then flips
green — the per-shape trampoline emitted by build_block_convert
ferries (10, 20) through to the sx closure and the side-effect
stores land in g_a / g_b.
2026-05-27 21:57:47 +03:00
agra
3bd6f26c96 ffi M5.A.next.5.1.A: build_block_convert — expected-failing lock-in
Step 5.1.A of the FFI plan (variadic heterogeneous type packs →
generic `Into(Block)` impl). The eventual step-5.2 impl body will
read `#insert build_block_convert($args, $R);` to emit a per-shape
`__invoke` `callconv(.c)` trampoline + Block literal. 5.1.A pins
the builder's expected output verbatim across three void-returning
pack shapes (0, 1, 2 args) plus one non-void shape (`f64 -> s32`)
that exercises the `return typed_fn(...)` branch.

Today: 4× "unresolved 'build_block_convert'" diagnostics — the
builder isn't in stdlib yet. The next commit adds it to
`library/modules/std/objc_block.sx` and the test flips green.

The per-position type names in the emitted source come from
`type_name(args[i])`; the slice itself is `[]Type` flowing through
the new-form variadic + bare-`$args` path that the recent
issues-0048/0049/0050 fixes unblocked.
2026-05-27 21:48:10 +03:00
agra
ec2a99a1a3 ffi issue-0050: monomorphizeFunction leaks pack-fn state — xfail lock-in
A generic fn (with `$T: Type` type params) called from inside a
pack-fn mono inherits the outer pack maps during its OWN body
lowering. Same root cause as issue-0048 — the lowering helper
doesn't save/null `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` — but on the generic-mono path
(`monomorphizeFunction`, ~line 8718) rather than
`lazyLowerFunction`.

`examples/175-generic-fn-pack-state-leak.sx` calls
`build(args: []Type, $ret: Type)` from a four-shape pack-fn. The
expected output is `len=0 / 1 / 2 / 4`; today's run reports
`len=0` for every shape because `build__void` was first
monomorphised under `probe()`'s mono (N=0) and `args.len` got
constant-folded to 0 inside the cached body. The next commit
adds the same isolation pattern to `monomorphizeFunction`.

Step 5 of the FFI plan (generic `Into(Block)` impl) needs the
`build_block_convert(args: []Type, $ret: Type) -> string` builder,
which trips this leak directly.
2026-05-27 21:44:39 +03:00
agra
64dcbca06a ffi issue-0049: new-form variadic cross-module LLVM crash — xfail lock-in
Migrating stdlib's `path_join` to the new variadic syntax
(`(..parts: []string) -> string`) surfaces a latent compiler bug:
`resolveParamType` and `packVariadicCallArgs` treat the new-form
declaration the same as the legacy `parts: ..string` and wrap the
element type in `sliceOf` regardless of whether it already is one.
The new form's `[]string` becomes `[][]string`; the call-site
marshal pack emits `[N x string]` (correct) but the callee stores
its slice param into a `[]([]string)`-typed slot. The shape
mismatch propagates as null/undef Refs that crash
`LLVMBuildExtractValue` inside `emitStrCmp` during emission.

`examples/121-ios-sim-bundle.sx` (existing) and the new focused
`examples/174-new-form-variadic-cross-module.sx` both fail today
with the segfault. The next commit fixes `resolveParamType` +
`packVariadicCallArgs` so both flip green. Stdlib's `format` /
`print` / `open` and the example fixtures stay on the legacy form
in this commit — they migrate in the follow-up cleanup commit.
2026-05-27 21:29:08 +03:00
agra
8fcf352de8 ffi issue-0048: bare $args slice loses .len across call — xfail lock-in
Bare `$args` evaluated inside a pack-fn body has the right `.len` /
per-element types inline, but the moment the same slice is passed
as an argument to another function, the callee silently reads
length 0 and every element comes back as undef.

Cause (per issue file): `lazyLowerFunction` saves/restores builder
state but not `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` / `inline_return_target`. When a regular fn like
`describe(args: []Any)` is lazily lowered from inside a pack-fn
mono, the outer pack maps are still active; `lowerFieldAccess`'s
`<pack_name>.len` intercept fires on `describe`'s same-named param
and bakes the outer mono's arity as a constant into describe's IR.
Every subsequent shape's call to describe returns that constant.

`examples/173-pack-bare-args-cross-call.sx` exercises four shapes
(0, 1, 3, 5 elements) through the same `describe(args: []Any)`
walker. The expected output holds the per-position type names
(`[s64]`, `[s64, string, bool]`, etc); today's diff fails — the
walker reads `args.len = 0` for every shape and returns `[]`. The
next commit fixes `lazyLowerFunction`.
2026-05-27 21:09:25 +03:00
agra
21626628cd ffi M5.A.next.4A.bare.5: end-to-end smoke for bare $args + dynamic reflection
Step 4A final-slice's smoke test. Exercises the FULL surface
step 5's generic Into(Block) impl needs to operate:

  1. A pack-fn binds $args (whole pack as []Type).
  2. The body walks `list := $args` at INTERP time.
  3. Per position, calls `type_name(list[i])` — the dynamic
     form that emits `callBuiltin(.type_name, ...)` at lower
     time, dispatched at interp time to read the runtime
     Value.type_tag and return the concrete type name.

`examples/172-pack-builder-smoke.sx` exercises four call
shapes via #run:
  describe()                      → []
  describe(42)                    → [s64]
  describe(42, "hi")              → [s64, string]
  describe(true, 3.14, "x", 99)   → [bool, f64, string, s64]

Each call shape builds its own [N x Any] slice of .type_tag
values at lowering time, the interp walks the slice, and the
per-element type names come out kind-honestly.

212/212 example tests + zig build test green.
2026-05-27 19:20:54 +03:00
agra
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 9600ba5) reads the runtime `.type_tag` Value and
  returns the per-position name.

`isStaticTypeArg(node)` is a new helper mirroring the explicit
arms of `resolveTypeArg`. Lives alongside resolveTypeArg in
lower.zig; documented to track shape changes together.

emit_llvm: the comptime reflection builtins (`type_name`,
`type_eq`, `has_impl`) now emit a silent undef-i64 placeholder.
Same reasoning as 4A.bare.1.B's relaxation of const_type's
emit_llvm arm: the JIT compiles the containing fn module-wide
even if main never calls it, so emit-time noise here is just
dead-from-main's-perspective code. Real misuse — passing a non-
Type value to one of these — is caught by the interp arm's
`asTypeId orelse bailDetail`.

`examples/171-pack-dynamic-type-name.sx` flips from "s64s64"
(silent .s64 fold per element) to "s64string" (per-position
correct via interp arm). Test runs `walk(42, "hi")` at `#run`
time so the dynamic path executes in the interp.

211/211 example tests + zig build test green.
2026-05-27 19:19:32 +03:00
agra
95e61d8a86 ffi M5.A.next.4A.bare.4.A: dynamic type_name(args[i]) — expected-failing lock-in
Step 4A final follow-up's lock-in. `type_name(<arg>)` where
<arg> is NOT a statically resolvable type expression (e.g.
`list[i]` indexing into a `$args`-derived `[]Type` slice)
silently folds to "s64" today because `resolveTypeArg`'s
index_expr fall-through returns `.s64` (the catch-all `else =>
.s64` at the bottom of the switch).

This is exactly the kind of silent unimplemented arm the
project's REJECTED PATTERNS section forbids — the user gets
"s64" for every element of an arbitrary pack, not the per-
position concrete type they expect.

`examples/171-pack-dynamic-type-name.sx` exercises a builder-
shaped fn: walks `$args` via runtime indexing, calls
`type_name(list[i])` per position, concatenates the results.
For `walk(42, "hi")` the expected output is "s64string".
Today's output is "s64s64" — the silent fold strikes twice.

Cadence shape 2: expected output is the WORKING shape; today's
diff fails. Next commit teaches `tryLowerReflectionCall` to
detect "arg not statically resolvable" and emit a builtin_call
to `.type_name` so the interp's runtime arm (wired in commit
9600ba5, M5.A.next.4.1) handles the dynamic case.

210/210 + 1 expected-failing = 211 total. zig build test green.
2026-05-27 19:16:19 +03:00
agra
c792642d76 ffi M5.A.next.4A.bare.1.A: bare $args — expected-failing test
Step 4A final slice's lock-in. `$args` (whole pack) as a bare
expression should evaluate to a comptime `[]Type` slice value
— the whole pack passed through as data so builder fns can
walk it.

Today's parser arm (commit fd03b58, M5.A.next.4.3) requires
the `[<int_literal>]` form: bare `$<pack_name>` hits the
focused "expected '[' after '$<pack_name>'" diagnostic I added
when wiring the indexed access.

`examples/170-pack-bare-value.sx` exercises four call shapes
of a pack-fn whose body binds `list := $args` then returns
`list.len`. Expected output (post-fix) is "0/1/3/4" per call.
Today the parser rejection diff makes the test fail —
209/209 + 1 expected-failing = 210 total.

Cadence shape 2: expected output is the WORKING shape; pre-fix
the parser-error diff fails. Next commit lands the parser
extension + AST node + lowering and the test flips green.
2026-05-27 19:05:16 +03:00
agra
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.
2026-05-27 18:52:41 +03:00
agra
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.
2026-05-27 17:48:39 +03:00
agra
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.
2026-05-27 17:26:27 +03:00
agra
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 69dcee8)
  flips from parse error to "42 first". Exercises both a
  return-type position (-> $args[0]) AND a local-var
  annotation (second : $args[1] = args[1]); two
  heterogeneous call shapes confirm distinct monos pick
  distinct concrete types per pack index.
- examples/166-pack-type-position-three.sx — three-element
  pack with $args[2] (third element) as return type. Three
  call shapes: (s64,s64,string), (bool,f64,s64),
  (string,string,bool). Prints "third 99 false".

Out of scope (deferred):
- $args[$i] where $i is a comptime-bound expression (only
  literal int supported in this slice).
- $args[$i] in fn-pointer type LITERALS (works for named
  decls but nested fn type expressions need an audit).
- $args[$i] in struct field types.

206/206 example tests + `zig build test` green.
2026-05-27 17:23:47 +03:00
agra
69dcee88cd ffi M5.A.next.3a.A: $args[$i] in type positions — expected-failing test
Step 3 of the variadic heterogeneous type packs feature.
`$args[$i]` (with `$i` a literal integer for the first slice)
should resolve to the i-th element type of the active pack
binding in every type position: return types, param types,
local var annotations, fn-pointer type literals, struct fields.

Today the parser hits "expected '{'" at the `$args[<lit>]`
token because the `$<ident>` arm in `parseTypeExpr` only
recognises plain generic names (`$T`, `$T/Eq/Hashable`).
After `<ident>`, an opening `[` is unexpected.

`examples/165-pack-type-position.sx` exercises two type
positions per mono — a return type `-> $args[0]` AND a local
var annotation `second : $args[1] = args[1]` — so the parser
change must cover more than the trailing return arrow. Two
call shapes (`swap_take(42, "ignored")` and `swap_take("first",
99)`) confirm heterogeneous monos pick distinct concrete
types per position.

Cadence shape 2: the expected output is the WORKING output
("42 first"); pre-fix the diff vs the parser-error output
fails. Next commit lands the parser + resolver changes and the
test flips green.

204/204 + 1 expected-failing = 205 total. `zig build test`
green.
2026-05-27 17:20:37 +03:00
agra
2993072972 main: "--- build done ---" delimiter on stderr for top-level #run
Tests that exercise top-level #run produce two interleaved
output streams: the interp's #run prints (flushed via
std.debug.print → stderr at core.zig:187/190) and the JIT-
executed main's prints (libc write fd=1 → stdout). When the
test runner captures both via 2>&1 the boundary between them
is invisible — the snapshot reads as one block.

Now `sx run` emits "--- build done ---\n" on stderr right
before invoking the JIT, when `hasTopLevelRun(root)` is true.
Tests without top-level #run keep their current snapshots
unchanged; only the 7 affected tests pick up the delimiter
between the build-time and run-time sections.

Example: 05-run flips from
    hello 25
    hello 25
to
    hello 25
    --- build done ---
    hello 25

— the first "hello 25" is from `#run main()` running at
compile time, the second is from JIT main() running at
runtime. The delimiter makes that explicit.

204/204 example tests + `zig build test` green.
2026-05-27 17:08:14 +03:00
agra
13efc565fa ffi issue-0046: nested comptime call + return — expected-failing test
Lock-in for issue-0046. The test file expects the WORKING
output ("inside" / "n=42") — pre-fix the interp panics
non-deterministically at `storeAtRawPtr` (null pointer store)
because `createComptimeFunction` does not save/restore the
outer `lowerComptimeCall`'s `inline_return_target` state; the
wrapper fn built for the nested `print` body inherits a slot
belonging to a different basic block.

Cadence rule shape 2: expected-failing test, the next commit
turns it green. Today the suite shows 1 failure (issue-0046);
post-fix it returns to all green.

The thread ID + hex addresses in the panic output are non-
deterministic so locking in the broken shape directly would
be flaky — comparing actual panic vs expected-working still
diffs as FAIL pre-fix, no need to snapshot the panic.

The pack-fn face of issue-0046 was fixed incidentally by step
2b (mono path bypasses the inline-return-slot setup that
leaked into nested comptime calls). Plain `($x: s32)` comptime
fns stay on the inline path and still need this fix.
2026-05-27 16:56:25 +03:00
agra
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.
2026-05-27 16:47:52 +03:00
agra
fc8a8c3f2e ffi M5.A.next.2b.fu1.A: mixed comptime+pack — lock in unresolved-tag miss
Follow-up #1 from step 2b: pack-fns that mix a non-pack
comptime param with the trailing pack (e.g. `tagged($tag: s32,
..$args)`). Today's `isPackFn` requires the pack to be the
ONLY comptime param; mixed shapes fall through to the inline
`lowerComptimeCall` path. That path adds non-string comptime
params to `comptime_param_nodes` for #insert substitution but
does NOT bind them as runtime locals, so the body's bare
`tag` reference hits "unresolved 'tag'" at the call site.

Next commit:
- Relax `isPackFn` to "exactly one trailing pack + any number
  of non-pack comptime params" so the mono path takes over.
- Fold comptime VALUES into the mangled name (`tagged(7, ...)`
  and `tagged(9, ...)` get distinct monos so each body sees
  its own comptime constants).
- Bind comptime args as both `comptime_param_nodes` (for
  #insert substitution) AND runtime locals (for bare-name
  references). String literals stay as string locals;
  int/bool/float literals become typed locals of the
  appropriate primitive type.

This is the load-bearing prerequisite for step 6 (stdlib
`print`/`format` refactor to `(\$fmt, ..\$args)`) — without
mixed-mode mono support, stdlib stays on the inline path
forever.

203/203 example tests + `zig build test` green (the lock-in
captures the wrong-shape diagnostic as the snapshot to flip).
2026-05-27 16:43:04 +03:00
agra
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.
2026-05-27 16:41:28 +03:00
agra
dadf80b3f1 ffi M5.A.next.2b.fu34.A: bare args + runtime index — lock in unresolved/LLVM errors
Lock-ins for follow-ups #3 (bare `args` reference) and #4
(`args[<runtime_int>]`) from step 2b. Both share the same root
cause: the pack-mono does not materialise an `[]Any` slice
value for the pack name, so any body that needs `args` as a
value at runtime fails.

`examples/162-pack-bare-args.sx` — pack-fn body forwards `args`
to a `[]Any`-typed helper. Today: "unresolved 'args' (in
... fn forward__pack_s64_string_f64)".

`examples/163-pack-runtime-index.sx` — pack-fn body indexes
`args[i]` with a runtime `i`. Today: LLVM verifier crash —
"GEP base pointer is not a vector or a vector of pointers" —
because `args` resolves to a junk Ref via the scope-lookup
fall-through, and the slice-indexing path emits a GEP off
that.

Next commit materialises an `[]Any` slice on demand inside the
mono: each pack param is boxed into Any, stored in a stack
[N x Any] array, and the slice {data_ptr, len} is bound to the
pack name. `args` then resolves as a runtime value the same way
the pre-2b inline path used to. `args[i]` runtime indexing goes
through the standard slice index path; element type is `Any`
(lossy on per-position types — inherent to runtime indexing
into a heterogeneous pack).

202/202 example tests + `zig build test` green.
2026-05-27 16:38:01 +03:00
agra
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.
2026-05-27 16:34:26 +03:00
agra
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.
2026-05-27 16:28:52 +03:00
agra
e44ba4b240 ffi M5.A.next.2b.fu2.A: generic \$R pack-fn — lock in silent-zero return
Follow-up #2 from step 2b: pack-fns with a generic return type
(`(..\$args) -> \$R`). Today's `monomorphizePackFn` calls
`resolveReturnType` which sees `\$R` as a generic name and
returns an opaque struct TypeId. The mono's ret_ty is wrong
and the value silently coerces to 0.

`examples/159-pack-generic-ret.sx` pins this: `first(42)` and
`first(99)` both return `0` instead of the call arg. The lock-in
captures the wrong output as the snapshot to flip.

Next commit infers the ret type from the body's tail expression
(arrow form) or the first explicit `return X;` (block form),
then builds the mono signature against that concrete type.

198/198 example tests + \`zig build test\` green.
2026-05-27 16:22:49 +03:00
agra
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.
2026-05-27 15:44:05 +03:00
agra
e6d6903708 ffi M5.A.next.2a.D: inline-return uses CFG terminator, not block_terminated
Fixes the regression locked in by 2a.C (commit 6b7a66b).
issue-0045's original fix set `block_terminated = true` after
each inline `return X;` to skip dead code in the inlined body.
But the flag leaked past structured control flow — an `if cond
{ return X; }` whose merge block continued to subsequent
statements would short-circuit the trailing code at the
`lowerBlockValue` loop's `if (self.block_terminated) return
null;` check.

Switched to the classical SSA "return-done block" shape:

- `InlineReturnInfo` carries a third field `done_bb: BlockId`
  — a fresh basic block allocated by `lowerComptimeCall` per
  comptime-call instance.
- `lowerReturn`'s inline path stores into the slot, drains
  defers, and emits `br done_bb`. The basic block's terminator
  is what carries the "no fall-through" signal; the
  `block_terminated` flag is no longer touched.
- `lowerComptimeCall` allocates the slot + done_bb, lowers the
  body, then switches to done_bb and loads the slot. Tail-
  expression bodies that fall through (rare when has_return is
  true) get a synthetic store + br so the CFG is well-formed.

For `if cond { return 42; }; return -1;`:
- cond=true: then's `return 42` stores 42, br done_bb. Merge
  block has only the false predecessor, doesn't run the
  trailing return. Load done_bb → 42.
- cond=false: condBr skips to merge. Merge runs `return -1;`
  → store -1, br done_bb. Load → -1.

`examples/157-pack-if-return.sx` flips from `8354116000` (the
uninitialised slot load on the false path) to `-1`. A
three-way `classify(..$args)` smoke confirms multi-path
inline-return works for any of the three branches.

Dead-code-after-return inside the inlined body still trips the
LLVM verifier (same shape as a regular `return X; print("dead");`
which also crashes today). Acceptable consistency — user code
shouldn't write unreachable code in either context.

196/196 example tests + `zig build test` green.
2026-05-27 14:55:25 +03:00
agra
6b7a66ba4d ffi M5.A.next.2a.C: pack if-return — lock in slot-load uninit regression
Follow-up to issue-0045's fix (commit 9e78790). The fix routes
inline-comptime-body `return X;` into a result slot but sets
`block_terminated = true` after the inline return — and that
flag leaks past the enclosing `if`'s merge block.

Body shape:
  maybe :: (..$args) -> s64 {
      if args.len > 0 { return 42; }
      return -1;
  }

For `maybe()` (zero call-args), the false-condition path skips
the then-branch's `return 42;` and should fall through to
`return -1;`. Today's flow:

  - Then-branch's `return 42;` stores 42 to slot and sets
    block_terminated = true.
  - if lowering switches to merge_bb. block_terminated stays
    true (never reset across the if/merge boundary).
  - lowerBlockValue's loop sees block_terminated and returns
    null without processing the trailing `return -1;`.
  - lowerComptimeCall loads slot — slot was never written on
    the false-condition path → garbage (8354116000 on this
    machine; stable across runs).

`maybe(99)` works because the cond is true; the then-branch's
store wins.

Next commit reshapes the inline-return mechanism to use a
dedicated "return-done" basic block: each inline `return X;`
stores to slot and branches to ret_done; after the body
lowers, lowerComptimeCall switches to ret_done and loads. The
basic block CFG carries the control-flow termination — no
need for the leaking `block_terminated` flag.

196/196 example tests + `zig build test` green (the new test
captures the wrong value as the snapshot to flip).
2026-05-27 14:52:43 +03:00
agra
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.
2026-05-27 13:55:19 +03:00
agra
223ec3d0b3 ffi M5.A.next.2a.A: pack typed indexing — lock in Any-untyped miss
Step 2 of the variadic heterogeneous type packs feature: typed
runtime indexing (`args[$i]` at comptime-known `$i`). Today's
pack-fn body lowers `args[i]` through the `[]Any` slice path —
the static type returned is `Any`, so any downstream field
access / typed-coercion / further indexing fails the moment it
needs more than primitive auto-unboxing.

`examples/156-pack-typed-index.sx` pins the simplest visible
failure: `args[0].x` on a struct-typed call arg trips
"field 'x' not found on type 'Any'" at the field-access site
because AST-level type inference for `args[0]` returns Any.

Next commit teaches `lowerIndexExpr` (and `inferExprType` for
the same shape) to detect an index_expr whose base is a
pack-name binding from the enclosing comptime call AND whose
index is a comptime int literal — substitutes the i-th
call-site arg's lowered value directly, propagating the call
arg's concrete type through field access, typed assignments,
and further indexing. The `[]Any` slice path stays as the
runtime-indexed fallback for `args[i]` where `i` is not a
comptime constant.

195/195 example tests + `zig build test` green.
2026-05-27 13:49:44 +03:00
agra
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.
2026-05-27 13:21:23 +03:00
agra
3d32ab0fc6 ffi issue-0045: pack-fn block-body call — lock in LLVM verifier crash
Filed `issues/0045-pack-fn-call-llvm-verifier-failure.md`.
Surfaced by probing step 2 territory of the variadic
heterogeneous type packs feature: any `..$args` fn whose body
is a block containing `return X;` (or any comptime fn with a
non-void return, comptime params, and explicit `return` in a
block body) trips LLVM's "Terminator found in the middle of a
basic block" verifier.

`lowerComptimeCall` inlines the body's statements directly into
the caller's LLVM function. `lowerReturn` then emits a `ret`
into the caller's basic block — but the caller still has
trailing instructions, hence the verifier failure.

`examples/issue-0045.sx` reproduces the crash with the minimum
pack-fn shape (`foo :: (..$args) -> s64 { return 42; }`). Same
shape with a plain comptime param (`($x: s32) -> s64 { return
42; }`) reproduces identically, so the bug is broader than
packs. Arrow-form bodies (`=> 42`) work today because they have
no `return` statement.

Next commit teaches `lowerComptimeCall` to allocate a result
slot when the body contains a `return`, and reroutes
`lowerReturn` to store into that slot + flag the block as
terminated so the inliner picks up the value.
2026-05-27 13:19:49 +03:00
agra
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.
2026-05-27 12:57:45 +03:00
agra
ce3c2fe7bd ffi M5.A.next.1d.A: pack impl matching — lock in concrete-only miss
Step 1d lock-in test pinning today's matching behaviour.
`registerParamImpl` records every impl in `param_impl_map` keyed
by `"Proto\x00<arg_mangled>\x00<src_mangled>"`. For a pack impl
`Into(Block) for Closure(..$args) -> $R` the key contains the
pack-shaped closure's mangle (interns with `pack_start = Some(0)`
after 1c.B). At the `xx cl : *Block` site the lookup mangles the
concrete `Closure(s32, bool) -> bool` source and finds nothing —
the existing focused diagnostic fires:

  no `Into(Block) for cl_s32_bool__bool` 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

The pack impl is reachable in the file but never considered.

Next commit (1d.B):
- New `param_impl_pack_map` keyed by `"Proto\x00<arg_mangled>"`
  (no src) — populated by `registerParamImpl` when the source
  is pack-shaped.
- `tryUserConversion` walks the pack map on concrete-key miss.
  Pack shape matches when the impl's fixed prefix equals the
  source's matching prefix; the remainder binds to `$args` and
  the source's return type binds to `$R`. Concrete impls win
  over pack impls (specificity).
- `resolveTypeWithBindings` learns the closure_type_expr path
  so the impl body's `self: Closure(..$args) -> $R` substitutes
  to the concrete source closure during monomorphisation.

The `Closure(s32, bool) -> bool` shape is not covered by stdlib
or 96-block-multi-arg's hand-rolled impls, so the pack impl is
the only candidate post-1d.B.

193/193 example tests + `zig build test` green.
2026-05-27 12:50:23 +03:00
agra
65824494a7 ffi M5.A.next.1c.B: pack type rep — Closure(..$args) parses + interns
`parseTypeExpr`'s `Closure(...)` arm now accepts a trailing
`..$name` (sigil optional) as a variadic-pack marker. Pack must
be terminal — `)` is the only token accepted after the name.
`ClosureTypeExpr` AST gains `pack_name: ?[]const u8` carrying the
identifier so later slices can name the binding.

`FunctionInfo` / `ClosureInfo` in src/ir/types.zig grow a
`pack_start: ?u32 = null` field. `Closure(..$args) -> R` interns
as `params = []`, `pack_start = Some(0)` — distinct from any
concrete `Closure(...) -> R` shape thanks to updated hash/eql
arms. New constructor pair `closureTypePack` /
`functionTypePack` keeps the existing single-shape constructors
unchanged.

`type_bridge.resolveClosureType` calls `closureTypePack` when
`pack_name != null`. The pack starts after the fixed prefix,
so `Closure(Prefix, ..$args)` resolves with `params = [Prefix]`,
`pack_start = Some(1)`.

No semantic effect yet — the signature exists in the type table
but no matching code reads `pack_start`. Step 1d wires impl
matching: `Closure(..$args) -> $R` binds against any concrete
closure source type in `tryUserConversion` / `registerParamImpl`.

`examples/154-pack-type-rep.sx` flips from rejecting-with-error
to positive parse smoke (prints "pack type rep ok").

192/192 example tests + `zig build test` green.
2026-05-27 12:12:16 +03:00
agra
bb6eca6b91 ffi M5.A.next.1c.A: pack type rep — lock in parser rejection
Next slice of the variadic heterogeneous type packs (`..$args`)
feature: type-system representation. Per the FFI cadence rule, this
commit locks in the parser-rejection behavior so the next commit's
type-rep extension surfaces as a behavior shift.

examples/154-pack-type-rep.sx uses `..$args` inside a `Closure(...)`
type expression — the pack-shape spelling used by impl headers like
`impl Into(Block) for Closure(..$args) -> $R`. Today's parser
recognizes `..$args` only at the parameter-list site (1b);
`parseTypeExpr`'s `Closure(...)` arm calls `parseTypeExpr` per
position and hits "expected type name" at the `..` token. Snapshot
captures the rejection at line 18, column 26.

Next commit (1c.B):
- Parser: `parseTypeExpr` Closure arm accepts `..$args` as the
  trailing pack marker. AST gets a `pack_name: ?[]const u8` (or
  equivalent) field on `ClosureTypeExpr`.
- types.zig: `FunctionInfo` / `ClosureInfo` gain `pack_start: ?u32`
  so the pack shape is distinct from any concrete arity in the
  type table. Hash/eql updated.
- type_bridge: `resolveClosureType` threads pack_start through.
- 154 flips green.

192/192 example tests + `zig build test` green.
2026-05-27 12:09:04 +03:00
agra
a51fe26cbf ffi M5.A.next.1b: parser accepts ..$args as a variadic-pack param
Extends parseParams in src/parser.zig:1558 to recognize a leading
`..` before the optional `$` sigil and the parameter name. The
old `args: ..T` form (variadic marker after the colon) still
works — both paths set the same `is_variadic` flag.

A pack declaration `..$args` parses as:
- `is_variadic = true` (from the leading `..`)
- `is_comptime = true` (from the `$` sigil)
- `type_expr = inferred_type` (no `:` annotation)

The no-colon branch now propagates `is_variadic` and `is_comptime`
onto the Param struct so later slices (type rep, impl matching,
monomorphisation) can read both flags from the parsed AST without
re-deriving from token sequence.

`examples/150-pack-parse.sx` flips from rejecting-with-error to
positive parse smoke. No semantic effect yet — `foo` is declared
but never instantiated.

191/191 example tests + `zig build test` green.
2026-05-27 09:49:41 +03:00
agra
ad82847b76 ffi M5.A.next.1a: variadic heterogeneous type packs — parse lockin
First slice of the `..$args` (variadic heterogeneous type pack)
feature. Locks in the current parser-rejection behavior so the
next commit's parser extension shows up as a behavior shift.

`examples/150-pack-parse.sx` declares `foo :: (..$args) -> s64`.
Today's parser hits `..` where it expects a parameter name
(parseParams in src/parser.zig:1558 only handles `..` inside the
type position after a colon) and emits "expected parameter name".
Expected output captures this rejection.

Per FFI cadence rule, this is the "test fails today, passes after
next commit's parser change" pair.

Pack feature plan saved at
~/.claude/plans/lets-see-options-for-merry-dijkstra.md ("Variadic
heterogeneous type packs" section). Motivates replacing the
hand-rolled per-signature `Into(Block)` impls with one generic
`impl Into(Block) for Closure(..$args) -> $R`; also unlocks
compile-time arity/type errors for `print`/`format`.

191/191 example tests + `zig build test` green.
2026-05-27 09:46:34 +03:00
agra
26329fe7ba ffi M5.A.3: multi-arg block smoke test (s32, *void) -> void
A signature the hand-rolled stdlib never covered: `Closure(s32, *void) -> void`.
Pre-M5.A this code wouldn't compile (no `Into(Block) for Closure(s32, *void) -> void`
declaration); post-M5.A the compiler emits `__block_invoke_v_i_p` on
demand and the call site goes through it.

The test uses two-arg side-effect capture (globals `g_sum`, `g_tag`)
to verify both args reached the closure body. Confirms the
trampoline's calling convention forwards
`(__sx_default_context, sx_env, arg0, arg1)` correctly through to
the closure's underlying fn.

Note: return-value signatures (e.g. `Closure(s32) -> s32`) are
recognised by the trampoline emitter — `cinfo.ret` flows through
to `beginFunction`'s return slot — but exercising them requires
closure-return-type inference that the test runner stumbled on
during authoring (`(n: s32) => { return n+1; }` infers void). The
void-returning shape is the more common Cocoa pattern (animation
bodies, dispatch_async, completion handlers); return-value
signatures land properly once the closure inference catches up
(orthogonal to M5.A).

190/190 example tests pass.
2026-05-27 00:26:30 +03:00
agra
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.
2026-05-26 23:10:00 +03:00
agra
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.
2026-05-26 23:02:08 +03:00
agra
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.
2026-05-26 22:58:30 +03:00
agra
8c3831acd2 test: M4.0 allocator-threading regression coverage
Two regression tests pinning down the silent-error surface in M4.0:

ffi-objc-arc-00 — single sx-defined-class instance round-trips
through a TrackingAllocator-wrapped GPA. Captures alloc/dealloc
deltas around the lifecycle, verifies (+1, +1). Pre-M4.0 the +alloc
IMP used libc malloc and -dealloc used libc free; tracker would
have observed (+0, +0) and missed the leak silently.

ffi-objc-arc-00b — three instances alloc'd and released. Catches
bugs where:
- the captured allocator becomes shared (one global slot vs
  per-instance);
- alloc captures the wrong allocator on the 2nd+ instance;
- dealloc reads garbage if state[0] is overwritten between
  instances.

Both tests are macos-only (libobjc + NSObject must be present at
runtime). Both wrap the lifecycle in `push Context.{ allocator =
xx tracker }` so the threading path is exercised.

Important authoring note: `print` inside the push-block also routes
through tracker (string formatting allocs), polluting the leak
delta. Tests capture before/after counts WITHOUT any prints between
alloc and release, then verify the BALANCE — every alloc paired
with a dealloc — rather than absolute counts. Discovered while
writing 00: an initial naive "leak_count() == 0" assertion failed
not because M4.0 was broken but because print's string allocs
weren't freed at scope exit.

187/187 example tests pass.
2026-05-26 22:46:56 +03:00
agra
29404afdee ffi M4.A: stdlib NSObject + autoreleasepool helper + extends rooting
Declare `NSObject` in std/objc.sx as `#foreign #objc_class("NSObject")`
with the canonical instance + class-method surface every Obj-C class
inherits: `retain`/`release`/`autorelease`/`new`/`alloc`/`init`/
`description`/`hash`/`isEqual_`/`isKindOfClass_`/`respondsToSelector_`/
`class`. Root the foreign-class hierarchy in uikit.sx at NSObject by
adding `#extends NSObject;` to every previously-unrooted declaration
(NSValue, NSNumber, NSDictionary, NSSet, NSNotification, NSBundle,
NSNotificationCenter, NSRunLoop, CADisplayLink, CALayer, EAGLContext,
UIScreen, UIResponder) plus deeper chain fixes (NSMutableDictionary
extends NSDictionary; UIWindow extends UIView; UIViewController
extends UIResponder). After this, M2.3's extends-chain walk finds
`retain`/`release` on any UIKit-typed value:

  view := UIView.alloc().init();
  defer view.release();        // canonical sx idiom — no language magic

Plus `autoreleasepool(body: Closure())` stdlib helper that wraps
`body` in `objc_autoreleasePoolPush` / `defer objc_autoreleasePoolPop`.
Required for Foundation factory returns; closure-call frame is real
cost so hot loops should inline the push/defer-pop pattern manually.

Smoke test `ffi-objc-arc-01-autoreleasepool.sx` exercises both
patterns; refresh of two IR snapshots picks up the new stdlib decls
appearing in test outputs that include `modules/std/objc.sx`.

185/185 example tests pass; chess on iOS-sim green.
2026-05-26 22:38:32 +03:00
agra
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.
2026-05-26 22:30:48 +03:00