inferExprType now returns .unresolved when it genuinely cannot infer a type,
instead of silently guessing .s64. To keep codegen correct, every consumer
that turns inference into a concrete type was fixed to resolve it properly
rather than lean on the fake s64:
- pack-fn mono: value-pack params type from the lowered Ref (getRefType);
comptime ..$args prefers inference (int-literal default is s64) and falls
back to the lowered type only when inference cannot tell.
- if-expr / match merge result type: fall back to the contextual target_type
when the branch/arm type is not statically inferable; a statement match with
non-value arms stays void (do not let a leaked target_type make it a value).
- inferExprType call arm: resolve a not-yet-lowered function return type from
fn_ast_map (void for a return-less fn) instead of falling through.
- lowerBinaryOp: type the result from the lowered LHS when inference is
unresolved (e.g. #objc_call(...) * 2).
- null comparison (x == null): lower the non-null side first and take the
null type from it, never a guess.
A consequence: `xx enum` with no target type now boxes as Any (prints the
variant name) instead of the silent-s64 int -- examples/52 snapshot updated to
the honest output. 236 examples + unit tests green.
An unannotated param resolving to a plausible .s64 was the classic
silent-default trap (root of the 2.5 multi-param-closure bug). Replace it
with a dedicated TypeId.unresolved at slot 0, so a zero-initialised or
forgotten TypeId trips the sentinel instead of masquerading as a real type.
- types.zig: TypeId.unresolved = 0 (void moves to 17); TypeInfo.unresolved;
sizeOf/toLLVMType @panic on it (codegen tripwire); hash/eql/printer cover it.
- type_bridge: inferred_type => .unresolved (was .s64).
- resolveParamType: emit "parameter 'x' has no type annotation" for a
genuinely-unannotated value param (comptime/variadic/pack params exempt --
they resolve via per-call substitution).
- lowerLambda: resolve unannotated params from the target closure signature;
otherwise emit "cannot infer type of lambda parameter".
- CLAUDE.md: .void documented as an UNACCEPTABLE failed-type sentinel (it
conflates with a real, heavily-checked type); prescribe a distinct
.unresolved-style value + codegen tripwire.
Snapshot churn: one .ir (ffi-objc-call-06) -- the runtime type-name table and
typeof match arms renumber by the new builtin slot; program output unchanged.
An untyped lambda (a, b, c) => ... now takes each param's type
positionally from the expected Closure(T0, T1, T2) -> R signature, for
heterogeneous param types, in both assignment and argument position.
Previously only the first param (or all-same-typed params) resolved:
lowerLambda's signature loop applied contextual typing into params, but
the return-type-inference temp scope and the body param binding both
re-resolved each param via resolveParamType -- which defaults an untyped
(inferred_type) param to s64. So b in Closure(s64, string) bound as s64
and b.len errored. Both sites now read the already-resolved signature
types params.items[user_param_base + i].ty (user_param_base skips the
pre-populated ctx/env slots).
Regression: examples/201-closure-contextual-params.sx.
Note: a generic return $R inferred through a closure-typed parameter is
still unresolved (folds into Phase 4 function monomorphization); concrete
returns work.
Add range loop syntax:
- runtime for start..end (i) { } counting loop, cursor optional, end exclusive
- comptime inline for start..end (i) { } comptime-unrolled body
The inline form binds the cursor as an int_val comptime constant per
iteration, so xs[i] over a heterogeneous pack substitutes the concrete
per-position element -- the canonical's pack-iteration vehicle
(inline for 0..sources.len (i) { sources[i].addListener(...) }).
- AST: ForExpr.range_end, ForExpr.is_inline
- parser: parseForExpr range vs collection form; suppress_call flag so
N (i) is not read as a call N(i) while parsing a range bound
- lower: lowerRuntimeRangeFor / lowerInlineRangeFor; evalComptimeInt;
comptimeIndexOf extends pack-index resolution beyond int literals
Revises spec's inline for i in 0..N to the no-in, range-first, paren-cursor
form. Regression: examples/200-for-range.sx.
`xs.T` projects each pack element's protocol type-arg into a type list, usable
in TYPE/signature positions:
- tuple type `(..xs.T)` → e.g. `(s64, string)` (new resolveTupleTypeWithBindings)
- closure sig `Closure(..xs.T) -> R` → e.g. `Closure(s64, s64) -> s64`, which
contextually types a closure literal (resolveClosureTypeWithBindings now
expands a protocol pack via packTypeArgs).
Wired `tuple_type_expr` into `resolveTypeWithBindings` (type_bridge's tuple
resolver is stateless — can't see packs). `packTypeArgs(pack_name, projection)`
is shared: bare `..xs` → element types (`pack_arg_types`); `..xs.T` → each
element's `impl Box(args) for elem` target_arg (`elementProtocolTypeArg` scans
`param_impl_map`). In type position `xs.T` parses as a dotted `type_expr`, so
packTypeElems splits on '.'. examples/199-pack-type-projection.sx.
This completes 2.3's core: all spread/projection forms — call-arg, tuple value,
tuple type, closure sig — now lower. The canonical's `Closure(..sources.T)` /
`mapper(..sources.value)` / `(..sources)` shapes are functional.
A `spread_expr` element inside a tuple literal now expands the pack into the
tuple's fields: `(..xs.get)` ≈ `(xs[0].get(), …, xs[N-1].get())` (Decision 2 —
a pack is stored by materializing a tuple). lowerTupleLiteral detects a
pack-spread element via packSpreadRefs and splices the per-element Refs as
fields (typed via getRefType); for Box(T) the materialized tuple is
heterogeneous. A spread whose operand isn't a pack falls through to the
existing spread_expr diagnostic (tuple-value spread not yet handled).
When any element is a spread, field-count ≠ element-count, so the contextual
target-tuple alignment is skipped (field types inferred from the expanded refs).
examples/198-pack-tuple-materialize.sx.
A pack spread in call-arg position now expands to N positional args:
`add2(..xs.get)` ≈ `add2(xs[0].get(), xs[1].get())` — the canonical's
`mapper(..sources.value)` shape. The call-arg loop detects a spread whose
operand is a pack (`..xs`) or a pack projection (`..xs.method`) and splices the
per-element Refs in; a runtime-slice spread (`..arr`) is still left to the
slice-variadic path.
Factored the per-element synthesis out of lowerPackValueProjection into
`lowerPackElems` (used by both projection-to-tuple and spread-to-args), plus a
`packSpreadRefs` helper. examples/197-pack-spread-call.sx (2- and 3-arg, mixed
element types).
`xs.<method>` over a constrained pack projects a (zero-arg) protocol method
across every element into a tuple: `xs.get` ≈ `(xs[0].get(), …, xs[N-1].get())`.
lowerFieldAccess intercepts `xs.<m>` on a pack base (where <m> is a protocol
method) and synthesizes/lowers `xs[i].<m>()` per element into a tuple_init.
For a parameterised `Box(T)` the projected tuple is heterogeneous (each element
returns its own T). examples/196-pack-value-projection.sx.
Surfaced and fixed a pre-existing bug: inferExprType didn't handle tuple field
access (`t.0` / `t.x`), so a mixed-size tuple like `(42, "hi")` inferred the
string field as s64 — the wrong type then drove a bad `print` pack mangle and
coerced the string to i64 (garbage). Added the tuple arm (numeric + named).
Regression: a `(s64, string)` case in examples/190-tuple-values.sx.
A protocol-constrained pack element exposes only the constraint protocol's
interface (the locked decision): `xs[i].<member>` is rejected unless `<member>`
is one of the protocol's methods. `xs[i].v` (a concrete field of IntCell, not
declared on Box) now errors, like a constrained generic — even though the
substituted element is concretely an IntCell.
monomorphizePackFn records the pack param's constraint protocol in a new
`pack_constraint` map (pack-name → protocol); lowerFieldAccess checks it on an
`xs[i]` (index_expr) base BEFORE substitution erases the "constrained to P"
context. Protocol method calls (`xs[i].get()`) pass — the name is in the
protocol. Regression: examples/195-pack-interface-only.sx.
`xs[i].get()` on a parameterised `..xs: Box(T)` pack now resolves — the
canonical `ValueListenable` shape. registerParamImpl, for a CONCRETE-struct
source, now also registers the impl's methods as `<Source>.<method>` in
fn_ast_map (like a non-parameterised impl), so UFCS finds them. Such methods
are already fully concrete (`impl Box(s64) for IntCell` → `get(self: *IntCell)
-> s64`), so there's nothing to monomorphize; generic/pack sources stay lazy in
param_impl_map. First impl wins on a name collision.
Heterogeneous parameterised packs work: each `xs[i]` binds a different T and
dispatches to its own impl. Regression:
examples/194-protocol-pack-parameterized.sx (Box(s64) IntCell + Box(string)
StrCell, order-independent).
Calling a protocol method on a pack element now works: `xs[i].greet()` on a
`..xs: Greeter` pack dispatches to the concrete element's impl, and elements
may be heterogeneous (Dog, Cat). This is the protocol-interface access the
pack is for. (Protocol method decls omit the implicit `self`; impls list it —
the earlier malformed `(self: *Self)` decls were why dispatch looked broken.)
Also fixes packArgConformsTo for non-parameterised protocols: it queried
`protocol_thunk_map`, which is only populated lazily when a protocol VALUE is
built with `xx`, so it false-negatived valid conformers. Now it queries
impl-declaration state directly — `param_impl_map` for parameterised protocols,
or `<ty>.<method>` entries in `fn_ast_map` for non-parameterised ones.
examples/193-protocol-pack-methods.sx (heterogeneous Dog+Cat pack, per-element
greet(), order-independent).
Each argument bound to a `..xs: P` pack must conform to P — previously the
constraint was decorative (any type was accepted). `lowerPackFnCall` now
captures the pack param's constraint protocol and checks each pack arg via a
new `packArgConformsTo`, which accepts: a plain-protocol impl
(`protocol_thunk_map`), any parameterised impl `P(<args>) for T` (scan of
`param_impl_map` for a `P\x00…\x00mangle(T)` key — the per-element type-args
are inferred from the impl, not written out), or an arg already erased to P's
own protocol struct. Non-conformers get a per-position error pointing at the
argument. Only enforced for a known protocol constraint.
Regression: examples/192-pack-non-conform.sx (a struct lacking `impl Show` in a
`..xs: Show` pack → diagnostic, exit 1).
Design decision: a protocol-constrained pack element is viewed THROUGH the
constraint protocol — only the protocol's interface (its methods, and the
projections xs.T / xs.value) is accessible, not arbitrary concrete members,
exactly like a constrained generic `T: Show`. So `xs[i].v` (a field on the
concrete IntBox, not declared on Show) is an error; the constraint is enforced
and bounds the body regardless of the concrete arg types at a call site.
The previous example 191 demonstrated `xs[i].v` — which only compiled because
the constraint is not yet enforced. Trimmed it to the protocol-agnostic part
that's correct today (per-shape binding + comptime `xs.len` across arities /
heterogeneous shapes); protocol-interface access + projection are the remaining
2.4 work. specs.md records the access rule.
`..xs: Protocol` now binds like the comptime `..$args` pack instead of
falling through to a runtime `[]Protocol` slice: each call site
monomorphizes with the concrete per-position arg types, and `xs[i]` is the
concrete element via AST substitution (Decision 1 — a pack is a comptime
mechanism, no runtime pack value). So `xs[i]`'s own fields/methods dispatch
statically and elements may be heterogeneous, while `xs.len` is a comptime
constant.
Mechanism: one `isPackParam(p) = is_variadic and (is_comptime or is_pack)`
predicate replaces the four `is_variadic and is_comptime` pack-detection
sites (call-arg split, mangle, arg lowering, monomorphizePackFn), and the
early call dispatch routes any `isPackFn` call to `lowerPackFnCall` before
the `hasComptimeParams` gate (which is false for a protocol pack).
examples/191-protocol-pack.sx exercises N=0, N=2, concrete field access, and
a heterogeneous IntBox+StrBox pack. Conformance checking and projection
(`xs.T` / `xs.value`) are the remaining 2.4 work.
A tuple_init's element values must match its field types exactly — LLVM
`insertvalue` does no implicit conversion. An inferred `pair := (40, 2)`
lowered its elements under the enclosing fn's `target_type` (e.g. main's
s32 return), producing i32 values, while the field types were inferred
independently as s64. The {i64,i64} aggregate was filled with i32
constants, so reading any element back returned garbage (40 + 2^32) and
tuple equality was always false.
lowerTupleLiteral now lowers each element under its resolved field type
(the contextual target tuple's fields when present, else per-element
inference) and coerces to it, so value width always matches field width.
Assignment to a tuple-typed field/element now also propagates the target
tuple type. Adds examples/190-tuple-values.sx as a regression test and
examples/probes/tuple-baseline.sx as the Step 0.4 audit artifact.
Feature 0 complete. addNote/addHelp bundle notes and help-blocks under a
primary diagnostic (handle from new addId/addFmtId); help blocks carry an
optional fix-it line that substitutes the suggested source. renderExtended
now renders primary -> notes -> helps with blank-line separators.
Wire the CLI to the extended renderer (renderErrors -> renderStderr) and
flip render_style default to .extended; the previous renderErrors ->
renderDebug path bypassed render() entirely, so flipping the field alone
was a no-op. 13 diagnostic snapshots re-rendered to the extended format.
`appendObjcEncoding` previously bailed on `.@"struct"`, which blocked
sx-defined `#objc_class` methods from declaring CGPoint / CGRect /
NSRange-shape signatures — the `class_addMethod` registration path
would emit a "type kind not yet supported by Obj-C encoding"
diagnostic. The helper now emits Apple's `{Name=field0field1...}`
form recursively, with a small `ObjcEncodingStack` (cap 16) that
breaks transitive struct→struct cycles by emitting the abbreviated
`{Name}` form instead of recursing forever.
`{Point=dd}`, `{_NSRange=QQ}`, `{CGRect={CGPoint=dd}{CGSize=dd}}`
all flow through the existing `objc_msg_send` + `class_addMethod`
path with no further plumbing.
Tests:
- `lower.test.zig` gains four cases: optional unwrap (single + nested),
flat struct (CGPoint, NSRange shape), nested struct (CGRect with
CGPoint+CGSize), bringing the helper's test coverage from
primitives + pointers to the full encoding table.
- `examples/ffi-objc-defined-class-02-struct-encoding.sx` exercises
a sx-defined `SxMover` class with `goto(p: Point)` setter and
`here() -> Point` getter end-to-end on macOS; the IR snapshot
confirms `v@:{Point=dd}` and `{Point=dd}@:` land in
`OBJC_METH_VAR_TYPE_` constants wired to `class_addMethod`.
Checkpoint cleanup: the "Next step (M1.2 A.1 — type-encoding
derivation table)" header in CHECKPOINT-FFI.md was stale (A.1
shipped in 6cc016c; A.0–A.7 all done; commit list now linked).
The encoding table stays as reference material.
224/224 example tests pass; zig build test green.
Previously, `t : Type = f64` stored a boxed string carrying the literal
name "f64"; comparisons and `type_of`/`type_name` round-trips lost the
underlying TypeId. This switches `Type` to a runtime-representable Any
pair: `{ tag = .any.index() (meta-marker), value = TypeId.index() }`.
Mechanism:
- `const_type` emits a 16-byte Any aggregate via insertvalue.
- `TypeId.any` advertises 16 bytes / 8-byte alignment so structs that
embed `t: Type` size correctly under verifySizes.
- `lowerBinaryOp` folds `==`/`!=` between static type-refs to a
`const_bool`, and decomposes runtime Any-vs-Any compares via
`unbox_any` so LLVM doesn't see icmp on aggregates.
- `lowerMatch`'s `is_type_match` path unboxes Any-typed subjects to
the i64 type tag before the switch, so `case type:` etc. fire.
- `lowerRuntimeDispatchCall` (used by `case T: ... cast(t) val`) does
the same unbox for the type-tag arg.
- `type_of(val: Any)` rebuilds an Any with `{.any, tag_of(val)}` so
the result is itself a `Type` value, not a bare i64.
- `buildPackSliceValue` stops re-boxing const_type — the value is
already canonical Any.
- `__sx_type_names` now indexes by TypeId across the whole table
using the new `types.formatTypeName` (structural names for `*T`,
`[]T`, `[N]T`, `?T`, `Vector(N,T)`, function/closure/tuple) so
runtime `type_name(t)` works for compound types.
- `interp.zig`'s comptime `type_name` accepts either the bare
`.type_tag` Value or the Any-boxed aggregate it now sees.
- `scanDecls` registers `Vec4 :: Vector(4, f32)` style aliases in
`type_alias_map` (before the `fn_ast_map` check; `Vector` IS a
`#builtin` fn). Lets `Vec4` in expression position lower as
`const_type(<vector tid>)`.
- `isStaticTypeArg` becomes scope-aware: a name shadowed by a runtime
local is not static. `isStaticTypeRef` is the symmetric helper for
the eq fold.
- `inferExprType` returns `.any` for bare type names (identifier and
type_expr) so pack arg types are correct.
Side effect: `print("{}", Vec4)` now prints the structural name
`Vector(4,f32)` rather than the alias literal `Vec4` — 12-meta's
expectation updated. Aliases stay pointer-equal to their target
(`Vec4 == Vector(4, f32)` is true).
Tests:
- examples/189-type-all-interactions.sx: 12-section comprehensive
coverage — literal `==`, `type_of(value) == T`, `Type` var storage,
`type_name` (static + runtime), printing Type values, generic
dispatch via `$T: Type`, `identity($T, val)`, `Wrap($T)`, reflection
builtins (`size_of`, `align_of`, `field_count`, `type_eq`),
`..$args` pack walking, `Type` in struct field, compound type
literals (`*Point`, `[4]s32`, `[]bool`, `?f64`).
- examples/12-meta.sx: expected output updated to reflect structural
name for the Vec4 alias path.
- ffi-objc-call-06-sret-return.ir: regenerated to absorb the new
type-name strings now emitted globally.
223/223 examples pass.
Generic `Into(Block) for Closure(string) -> void` (step 5.2) emits
a trampoline whose `callconv(.c)` param type collapses through
`abiCoerceParamType`'s `string → ptr` heuristic — the libc
"char *" convention. The caller side (typed fn-pointer cast +
indirect call through `b.invoke`) keeps the full `{ptr, i64}`
slice. Result on AArch64: caller passes 16 bytes in x0+x1,
trampoline reads 8 bytes from x0 only, the slice len is lost or
mis-tracked, and the trampoline's `memcpy` from the half-formed
string segfaults.
`examples/188-block-string-arg.sx` pins the post-fix behaviour
("got: <hello>"). Today's run segfaults inside the trampoline's
first read. The next commit splits `abiCoerceParamType` into a
foreign-only path (extern decls keep the libc collapse) and a
preserve-slice path (sx-internal `callconv(.c)`).
`compile_error(msg)` raises a build-time diagnostic at the call site
with `msg` as the error text. The arg must be a string literal —
runtime expressions can't be reported as compile errors. Used by
builder fns to reject malformed pack shapes / arg combinations
cleanly instead of silently emitting wrong code.
Today: `unresolved 'compile_error'`. Expected (post-fix): focused
diagnostic with the literal message at the call site's span. The
next commit adds the lowering arm.
All six produce their target outputs cleanly today; renamed out of
the `issue-*` namespace per CLAUDE.md "Resolving an open issue":
| Old | New |
|----------------------|-------------------------------------------|
| issue-0032 | 181-impl-duplicate-same-file |
| issue-0041 | 182-compound-type-in-expression |
| issue-0042 | 183-type-alias-size-align |
| issue-0044 | 184-objc-defined-class-method-self |
| issue-0045 | 185-pack-fn-comptime-return |
| issue-0046 | 186-nested-comptime-return |
Comment headers tightened to feature-focused (drop the issue-NNNN
provenance — that's in git history now). Missing expected `.txt` /
`.exit` files captured for 0041 + 0042 (they were untracked because
the bugs were fixed silently in adjacent work).
`examples/issue-*` after this commit: just `issue-0030.sx` — a
feature request (`extern G : T;` cross-file globals) that's never
been implemented. Staying in the issue namespace as a parked
proposal until the feature lands or gets formally rejected.
220/220 example tests + `zig build test` green.
Both repros emit their target diagnostics cleanly today (verified
2026-05-28 against HEAD):
- `issue-0033` → "no visible xx conversion from 's64' to 'Wrap'
— impl exists in another module but is not imported". Catches
the case where an `impl Into(X) for Y` is registered globally
via one module's import chain but is NOT transitively imported
by the file containing the `xx` site.
- `issue-0034` → "duplicate xx conversion from 's64' to 'Wrap':
impls in <a> and <b>". Catches two impls covering the same
(Source, Target) pair both reachable from a single `xx` site.
Renamed to focused feature names:
- `issue-0033*` → `179-impl-visibility*` (4 files: main + impl +
types + user).
- `issue-0034*` → `180-impl-duplicate*` (4 files: main + impl-a +
impl-b + types).
Path references inside the files updated. Comment headers tightened
to feature-focused (drop issue-NNNN provenance — that's in git
history now). Expected `.txt` / `.exit` files captured against the
full diagnostic text and exit code 1.
The `issue-*` namespace in `examples/` now shrinks to the literal
list of UNRESOLVED bug repros. 218/218.
`format` and `print` move from `..args: []Any` to `..$args`. The
pack-fn machinery monomorphises each call shape, so the
build_format-emitted body's `any_to_string(args[i])` substitutes
to the i-th concrete-typed call arg via packArgNodeAt — no more
runtime Any-boxing for static args. The Any boxing path still
fires for arg positions whose types collapse to `.any` (already
Any-typed inputs).
Net effect:
- Calls with statically-typed args produce per-shape monos
(`print__ct_<fmt_hash>__pack_s64_string_bool` etc). The mono
cache key now reflects both the format string AND the arg
types, so different shapes get distinct emit paths.
- Compile-time arity errors are now possible (callers passing
the wrong number of args mismatch the mono's positional
binding instead of silently mis-boxing).
- Optionals flow through the new `case optional:` arm in
`any_to_string` (commit ce77867); the variadic auto-unwrap
in `packVariadicCallArgs` stays as a fast-path but is no
longer load-bearing.
IR snapshots regenerated for 13 tests where the print/format
mono shape changed the string-constant pool: 142, the ffi-jni
test cluster, ffi-objc-call-03/06, ffi-objc-dsl-07. Test
08-types' undef-memory-read snapshot also shifted (the test
exercises `field = ---` reads from a print call's stack
neighbours; the new pack-mono lays out its stack frame
differently, so the previously-stale 1s now read as 0s — same
undefined behaviour, different garbage).
218/218 example tests + `zig build test` green.
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.
`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.
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.
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.
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.
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'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 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.
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.
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.
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.
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.
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.