Stdlib:
- `format` / `print` in std.sx — both move from `args: ..Any` to
`..args: []Any`. The post-issue-0049 lowering makes this safe
across module boundaries.
- `open` in fs.sx — `args: ..s32` → `..args: []s32`. Foreign
C-variadic semantics are preserved (the trailing `, ...` lands
in the generated `declare` regardless of which surface form is
used).
Examples:
- `19-varargs.sx` — `sum` / `print_all` migrated.
- `20-any-varargs.sx` — `print_any` / `count` migrated.
- `50-smoke.sx` — `typed_sum` migrated.
- `120-interp-variadic-any.sx` — comment-only update referencing
the new form.
- `ffi-foreign-cvariadic.sx` — three C-variadic foreign decls
migrated; header comment refreshed.
Suite stays at 214/214. The legacy `name: ..T` surface form is
still accepted by the parser; rejection follows in a later commit
once specs.md catches up.
Both helpers now detect when a variadic param's declared type is
already a slice (`..name: []T`) and use it as the element-shape
container directly, instead of wrapping it once more. The legacy
form (`name: ..T`) still wraps as before. Without the unwrap, the
new-form `..parts: []string` ends up with a callee-side slot type
of `[]([]string)`, while the call-site marshal pack emits a
`[N x string]` array, and downstream LLVM emission crashes on
the resulting null Refs (`LLVMBuildExtractValue` inside
`emitStrCmp`).
`examples/121-ios-sim-bundle.sx` (which exercises stdlib's
migrated `path_join`) and the focused regression
`examples/174-new-form-variadic-cross-module.sx` both flip green;
suite stays at 214/214. The remaining stdlib decls (`format` /
`print` / `open`) and example fixtures land in the follow-up
migration commit.
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.
`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.
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`.
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.
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.
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.
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.
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.
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.
Step 6 + 7 of the .type_tag activation plan. Audit pass on the
Any-boxing and value-display paths to confirm `.type_tag`
flows cleanly OR fails loudly.
Audit findings:
- `box_any` (interp.zig:1168) stores fields[0] as `.int(TypeId)`
for the Any-tag, fields[1] as the raw operand Value. A
`.type_tag` operand becomes the value field — correct.
Tag-field stays int-shaped across all Any boxes; value
field can be any Value kind including type_tag.
- `unbox_any` (interp.zig:1176) returns fields[1] as-is —
preserves whatever was stored. Correct for `.type_tag`.
- `any_to_string` (std.sx:316) has a `case type:` arm:
case type: { s : string = xx val; result = s; }
KNOWN GAP. Pre-`.type_tag`, the Any's value field was
string-shaped (lower-time type_name folding to const_string).
Now the value field will be `.type_tag(TypeId)`. The
`xx val to string` cast becomes a shape mismatch. Deferred
until source construction wires a path that surfaces this —
the loud bitcast guard below catches the silent-fall-through
case.
New guard:
- `bitcast` interp arm (interp.zig:664) now explicitly bails
when source is `.type_tag` and target is anything OTHER than
`.any` (boxing into Any) or the identity Type. Catches the
case-type-arm scenario above + any other stale "xx val to
string" path that would silently misinterpret a Type value.
Diagnostic suggests using `type_name(val)` as the
replacement.
No code changes in box_any / unbox_any (already correct).
208/208 example tests + `zig build test` green. No `.type_tag`
constructions exercised yet — the guards are dormant infrastructure
ready for when source construction surfaces them.
Second slice of the .type_tag activation. The reflection
intrinsics (`type_name`, `type_eq`, `has_impl`) now have
interp-time implementations that read `.type_tag` Values
directly. Today's lower-time fast path (folding to
`const_string`/`const_bool` when the type arg is statically
resolvable) stays — these interp arms are the fallback path
for when lowering emits a real `builtin_call` because the
arg is interp-time-only (e.g. `args[i]` inside a builder body
where the pack element is bound at interp execution).
Plumbing:
- New BuiltinId entries: `type_name`, `type_eq`, `has_impl`.
- Interp arms in `execBuiltinInner`:
- `type_name(t)`: reads `.type_tag` via `asTypeId`, looks up
via `module.types.typeName`, dupes the slice into the
interp allocator, returns `.string`. Non-`.type_tag` arg
→ `bailDetail` ("argument is not a Type value").
- `type_eq(a, b)`: both args must be `.type_tag`; compares
TypeIds. Either side missing → `bailDetail`.
- `has_impl(P, T)`: bails with a "not yet wired" message —
interp-time has_impl needs a queryable snapshot of the
host's `protocol_thunk_map` + `param_impl_map`, which is
its own follow-up slice. Static-arg has_impl still works
via the lower-time `tryConstBoolCondition` fast path.
- emit_llvm: explicit arms for the three new builtins that
log + map to undef-i64 (Type values are comptime-only; if
one of these reaches LLVM emit, lowering produced wrong
IR — the LLVM verifier downstream surfaces the offending
site).
Three new Zig unit tests in interp.test.zig:
- `type_name builtin on type_tag` — emits a `builtin_call`
to `type_name` with a `const_type(s64)` operand, asserts
the result is the string "s64".
- `type_eq builtin on type_tag values` — two equal Type
operands compare equal.
- (Pre-existing) `const_type yields type_tag` + `type_tag
comparison` from 4.0 still pass.
208/208 example tests + `zig build test` green. No source-
language path constructs `.type_tag` yet — the foundation is
ready for the `$args`-in-expression-position slice that
turns it on for users.
Wires the dormant `Value.type_tag(TypeId)` variant in interp.zig
so Type values flow through the comptime interpreter as
first-class kind-distinguished entities. No source-language
construction path yet — that's a follow-up. This commit is the
infrastructure foundation.
Audit findings (from interp.zig switch-walk):
- Every `else =>` arm over Value is either already loud
(`bailDetail` / `error.TypeError`) or a pass-through helper
(`materializeCtxArg`, `materializeForCall`, `resolveSlotChain`)
where transit-unchanged is semantically correct for type_tag.
No new silent paths introduced by activating the variant.
- The three pre-existing `.type_tag => return bailDetail(...)`
arms (store-at-raw-ptr, deref-non-pointer, unbox-non-aggregate)
already cover the disallowed paths cleanly.
New plumbing:
- `Op.const_type: TypeId` — dedicated opcode. Never piggybacks
on `const_int`. Result IR-type is `.any` to signal "untyped
at runtime" so downstream coercions fail loudly.
- `Builder.constType(tid)` constructor.
- Interp arm emits `Value{ .type_tag = tid }` for the op.
- emit_llvm arm bails loudly + emits an undef-i64 placeholder
(Type is comptime-only — if a Type ever reached LLVM emit,
some upstream builder leaked through; the diagnostic + LLVM
verifier downstream surface the offending site).
- `print.zig` arm prints `const type(<typeName>)`.
- `Value.asTypeId() ?TypeId` helper — the kind-honest accessor
for Type values. asInt/asFloat/asBool/asString continue to
return null for `.type_tag` (no silent coercion).
- `evalCmp` arm for `.type_tag, .type_tag` — TypeId equality.
Mixed `.type_tag` vs `.int` deliberately falls through to
the typeErrorDetail bail (a Type is not an int).
Tests (src/ir/interp.test.zig):
- `const_type yields type_tag` — confirms the variant is
produced and that asTypeId/asInt distinguish correctly.
- `type_tag comparison` — exercises cmp_eq on equal and
unequal pairs, asserts the right bool comes back.
208/208 example tests + `zig build test` green. No user-visible
behaviour change yet — `.type_tag` is constructible from Zig-
side IR builders but no sx-level syntax produces it. Next slice
wires `$args` lowering (or `$args[i]` in expression position)
to emit `const_type` per pack element.
Logs the 4-commit step-3 batch (69dcee8 → 8b457ff):
- 3a.A/3a.B: parser + AST + resolver for `$args[$i]` in type
positions (return, param, local-var annotation).
- 3a.C: extend resolution to fn-pointer type literals — the
shape step 5's generic Into(Block) trampoline body needs.
- 3b: `type_eq` + `has_impl` comptime intrinsics (`type_name`
already existed). Both fold via `tryConstBoolCondition` so
`inline if type_eq/has_impl` collapses at lower time.
Step 5 (generic Into(Block) impl) is now type-system-unblocked.
Step 4 (#insert pack passthrough + compile_error) is the
smaller intermediate slice if needed before pushing into the
stdlib refactor.
Issue 0047 (`#run` stderr vs runtime stdout split) noted in
"Current state" — filed but not blocking.
Test count: 208/208.
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.
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.
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.
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.
Surfaced while adding the `--- build done ---` delimiter
(commit 2993072). `#run print()` output is buffered by the
interp and flushed via std.debug.print → stderr at
core.zig:187/190; JIT runtime `print()` writes via libc
write(1, ...) → stdout. Same `print` call from the user's
viewpoint, different streams in practice.
Not blocking step 3 — tests capture both streams via 2>&1 so
snapshots are unaffected. Issue file documents the fix path
(move the two `std.debug.print` flushes in core.zig to
stdout-writes) for a future session.
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.
Logs the 13efc56 lock-in + 248d6e6 fix for issue-0046.
`createComptimeFunction` now saves/restores eight pieces of
outer lowering state so the wrapper fn (which the interp
executes in isolation) does not inherit caller state that
would corrupt its body lowering.
Pack-fn face was already fixed by step 2b; this commit closes
the plain `(\$x: s32)` comptime face.
Test count: 204/204.
Outstanding items reduced to: non-literal comptime args in
mixed-mode pack-fns (degrades to `?` mangle today).
`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.
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.
Logs all four step-2 follow-up commits (c917f92, 2e0b97a,
d30d566, 159f898 + their lock-ins). Pack-fns are functionally
complete on the mono path.
Test count: 203/203. Step 6 (stdlib print/format refactor) is
now unblocked from the type-system side; step 5 (generic
Into(Block) impl) still needs step 3 (type-position
$args[$i]) before its trampoline body can be parametric.
iOS-sim chess regression-verified post-step-2b.
Outstanding (not blocking step 3): issue-0046, non-literal
comptime arg mangling.
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.
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).
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.
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.
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.
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.
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.
Logs commit 7989618 (the 2b shift from inline expansion to real
shared mono fns) and updates the test count to 197/197.
Pack feature step 2 is fully done — typed indexing, control-flow,
and per-call-shape mono all land. Step 3 (type-reflection
intrinsics) is the next slice and unlocks the stdlib payoff
(generic Into(Block) for Closure(..$args) -> $R) by enabling
$args[$i] substitution in type positions.
Documented follow-ups: mixed $fmt+..$args shapes, generic $R
binding, bare args reference, runtime indexing, and issue-0046
fix all remain deferred — none block step 3.
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.
Logs the 2a.C/2a.D pair (6b7a66b + e6d6903) — the if-return
regression in issue-0045's original fix, now resolved via the
SSA "return-done block" pattern.
Step 2 is functionally sufficient for the impl-matching payoff
(step 5 of the plan); per-call-shape monomorphisation deferred
until a real workload demands it.
Step 3 ("Type-reflection intrinsics") flagged as the natural
next slice: `$args[$i]` in type positions unlocks the block-
trampoline body in stdlib's generic Into(Block) impl, which is
the visible payoff of the whole pack feature.
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.
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).
Comptime fn body containing BOTH a nested comptime call
(`print(...)`) AND a `return X;` fails in one of two shapes
depending on the comptime-param flavour: a `storeAtRawPtr`
panic in the interp (plain `$x: s32` comptime) or "unresolved
'result'" at compile time (pack-fn `..$args`).
Same root: my issue-0045 fix's `inline_return_target` slot
setup interacts badly with the recursive comptime-call path
that invokes `#insert build_format(fmt)` → interpreter →
parse-and-lower of `result := ...` statements.
Pre-issue-0045-fix the pattern crashed at the LLVM verifier
("Terminator found in the middle of a basic block") so the
recursive path never ran. The fix exposed the deeper bug; it
didn't create it.
Not blocking the next pack-feature slices:
- Step 2a tests use arrow-form bodies with no nested print.
- Steps 2b/3 don't inherently require nested comptime calls —
builders run inside `#insert` contexts, not inside public
pack-fn bodies.
- Will bite when step 5 refactors stdlib's `print`/`format` to
`..$args` or when user code writes a pack-fn with both
`print` debug output and an early `return`.
Investigation prompt in the issue file points at
`createComptimeFunction`'s saved/restored state list (missing
`inline_return_target`, `pack_arg_nodes`,
`comptime_param_nodes`) as the most likely angle.
Logs the two step-2a commits (223ec3d lock-in, cd36784 fix):
pack-fn bodies' `args[<int_literal>]` now substitutes the i-th
call-site argument's lowered value directly, propagating the
concrete type through field access and typed coercion.
Out-of-scope follow-ups noted: non-literal comptime indices,
type-position `$args[$i]` (step 3), per-mono mangling, and the
pre-existing nested-comptime-call scope bug (a pack-fn body
calling `print(...)` AND containing `return X;` trips
"unresolved 'result'" — same shape as the issue-0045 family,
worth filing separately if a later slice needs it).
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.
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.
Logs the issue-0045 fix (inline-return slot for comptime-call
bodies, commits 3d32ab0 + 9e78790) — the LLVM verifier crash
that surfaced when probing step 2 of the pack feature. Test
count now 194/194 with the new regression test.
`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.
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.
Logs the four commits that closed out step 1 of the variadic
heterogeneous type packs feature:
- 1c.A: parser-rejection lock-in for `..$args` inside `Closure(...)`.
- 1c.B: parser + AST + types.zig `pack_start` representation.
- 1d.A: impl-matching concrete-only miss lock-in.
- 1d.B: pack-aware impl matching with $args + $R binding through
`param_impl_pack_map` and `pack_bindings`.
Next step is plan step 2 — runtime `args[$i]` indexing + per-mono
mangling — opening the door to body-side pack reflection that
step 5 needs to retire the hand-rolled `Into(Block)` impls.
Also catches up the 1b entry which the prior session left
uncommitted in the working tree.
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.
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.
`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.