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.
`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.
Three general fixes to AST-level type inference that previously fell
through to `.s64`:
- `inferGenericReturnType` resolved the function's return type only
when `tmp_bindings` was non-empty; otherwise it bailed to `.s64`,
which silently mis-typed pack-fns with non-generic literal return
types (e.g. `walk(..$args) -> string`). Always resolve via
`resolveTypeWithBindings`, even with empty bindings.
- `inferExprType` `binary_op` arm: `.in_op` now returns `.bool`
alongside the other comparison/logical ops. Previously the `else`
branch returned the LHS type (e.g. `2 in (1,2,3)` → `s64`).
- `inferExprType` field-access call arm: when a namespace-qualified
call (`pkg.hello()`) hasn't been lowered yet, consult `fn_ast_map`
for the qualified name AND the bare field name (matches
`lowerCall`'s effective-name resolution order). Without this,
cross-module calls returned `.s64`.
Surfaces during the still-deferred print/format → `..$args`
migration where the pack mono's per-position type tag depends on
correct call-arg type inference. The fixes themselves are general
improvements that stand independently. 217/217.
The special-case `return self.fail("legacy variadic syntax ...")`
in `parseParams` is gone. `parseTypeExpr` already errors naturally
on a leading `..` (now reported as "expected type name"), which
is enough — the language-level cutover happened in the previous
commit; no need for the parser to keep a migration breadcrumb.
Removes `__block_invoke_void` / `__block_invoke_bool` and their
companion `Into(Block)` impls from
`library/modules/std/objc_block.sx`. The generic
`Into(Block) for Closure(..$args) -> $R` impl from step 5.2 now
covers both shapes (and every other closure shape) via per-mono
`#insert build_block_convert($args, $R)` source emission.
Net stdlib shrinkage: ~52 lines, two trampolines + two per-shape
impls down to zero. Adding a new block-shape consumer no longer
requires touching stdlib — the impl emits per-call-shape on
demand.
`examples/95-objc-block-noop.sx` (zero-arg closure) and
`examples/96-objc-block-multi-arg.sx` (user-declared per-shape
impl for `Closure(s32, *void) -> void`) still pass: 95 routes
through the new generic, 96 keeps its in-file impl as a
documentation example of the user-declares-their-own path.
Suite at 217/217.
Adds the generic `impl Into(Block) for Closure(..$args) -> $R`
in `library/modules/std/objc_block.sx` alongside the existing
hand-rolled `Closure() -> void` and `Closure(bool) -> void`
impls. The convert body is a single
`#insert build_block_convert($args, $R);` — per-call-shape
monomorphisation re-runs the builder so each closure shape gets
its dedicated nested `callconv(.c)` trampoline + Block literal.
The impl-mono path threads pack types through
`pack_bindings[args]` and the single-type return through
`type_bindings[R]`. Both need to be visible to the body's
`$args` / `$R` expression-position references — the existing
lowering only consulted `pack_arg_types` (set by pack-fn mono,
not by tryPackImplMatch). Two small extensions:
- `lowerExpr`'s `.comptime_pack_ref` arm now consults
`pack_arg_types` → `pack_bindings` → `type_bindings` in order,
treating a `type_bindings` hit as a single `const_type(T)`
value rather than the slice form.
- `resolveTypeArg` grows a `.comptime_pack_ref` arm that maps
the same name through `type_bindings` so type-arg positions
(e.g. inside `type_name(...)` in the builder body) resolve
the bound single Type.
- `type_bridge.isTypeShapedAstNode` lists `comptime_pack_ref`
and `pack_index_type_expr` as type-shaped so
`buildTypeBindings`'s strategy-1 explicit-arg path picks
them up when calling a `$T: Type`-generic fn.
`examples/177-generic-into-block.sx` flips green: a
`Closure(s64, s64) -> void` (no hand-rolled impl) is converted
through the generic impl, its block invoked via a typed
`callconv(.c)` fn-pointer, and the closure's side effects land
in the host globals. Hand-rolled impls remain for `()` and
`(bool)` shapes; 5.3 deletes those once a focused test covers
their behaviour through the generic path. Suite at 217/217.
`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.
`build_block_convert(args: []Type, $ret: Type) -> string` emits
the convert-body source for the generic `Into(Block) for
Closure(..$args) -> $R` impl (step 5.2):
1. A nested `__invoke :: (block_self: *Block, arg0: T0, ...) ->
R callconv(.c) { ... }` trampoline matching the per-shape
Apple Block ABI.
2. A `return Block.{ ... };` literal whose `invoke` slot points
at the nested trampoline via `xx @__invoke`.
Void-returning shapes emit `typed_fn(block_self.sx_env, args...)`;
non-void emits `return typed_fn(...)`. Per-position arg names
follow `arg0`, `arg1`, ... in declaration order; the typed-fn
cast reconstructs the closure's call signature so the trampoline
hands control back to `sx_fn` with the right argument layout.
`examples/176-build-block-convert.sx` flips green (216/216).
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.
Adds the same save+null+defer-restore block at the top of
`monomorphizeFunction` that landed in `lazyLowerFunction` for
issue-0048. The outer pack-fn's `pack_arg_nodes` /
`pack_param_count` / `pack_arg_types` / `inline_return_target`
are now suppressed for the duration of the generic mono's body
lowering and restored on exit.
`examples/175-generic-fn-pack-state-leak.sx` flips green
(len=0/1/2/4 across the four pack shapes); suite stays at
215/215.
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.
Parser hard-rejects the legacy `name: ..T` form with a one-line
migration message pointing at the new `..name: []T` shape. The
leading-`..` form is the one the lowering paths
(`resolveParamType` / `packVariadicCallArgs`) treat as canonical
post-issue-0049; leaving both forms accepted invited the same
class of cross-module emit crashes any time a `..T`-form decl in
stdlib crossed an import boundary.
`specs.md` updated alongside: the Variadic Functions section now
documents `..name: []T` as the surface form, with notes on
homogeneous vs `[]Any` boxing and the `..` spread at call sites.
Inline references to `args: ..Any` in §7 and §8 refreshed.
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.