`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).
`..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.
Add the name-resolution primitives a `..pack.<name>` projection needs
(Decision 4). A protocol exposes two namespaces: type-args (the
`protocol($T, ...)` params) and runtime accessors (its methods — protocols
have no fields). Resolution is position-driven with no cross-namespace
fallback:
- lookupProtocolArg(protocol, name) -> ?u32 (type_params index)
- lookupProtocolField(protocol, name) -> ?u32 (methods index)
- resolvePackProjection(protocol, name, pos) (.type_arg | .method | .not_found)
registerProtocolDecl now warns when a type-arg and a method share a name
(allowed, but `..pack.<name>` then resolves by position, which surprises
readers). 3 unit tests cover both namespaces, the position rule, and the
shadowing warning + deterministic resolution despite a shadow.
Projecting a *bound* pack (producing a new Pack of per-element results) waits
for call-site binding in Step 2.4; these primitives are what it will call
per element.
root.zig had no `test` block, so the test binary discovered zero tests and
trivially "passed" — every src test had silently rotted. Add
`refAllDecls(@This())` to root.zig so all 185 tests run, then fix the rot it
surfaced:
- emit_llvm.test: operands were constants, so LLVM folded the very
instructions being asserted (fadd/sub/icmp/insertvalue/extractvalue/sext).
Rewrite to use function-parameter operands; `main` now returns i32 (entry
convention); tagged-union enum_init lowers via memory, not insertvalue.
- interp.test: switch the per-test allocator to an arena (the interpreter is
arena-style and intentionally frees little) — clears the transient-Value
leaks without an ownership-ambiguous source change.
- lower.test: pass `is_imported` to lowerFunction; mark two helpers `pub`; the
if/else block test now uses a runtime (param) condition since lowering folds
`if true`.
- print.test: SSA numbering — params occupy %0/%1, so consts start at %2.
- jni_java_emit.test: nested-class refs render in Java source form
(`SurfaceHolder.Callback`), not the JNI `$` form.
Leaks fixed at the source where ownership was clear: Module gains an arena for
the operand slices the Builder dupes (struct/call/branch/switch args, block
params, lowerFunction params); objcDefinedStateStructType builds its field
slice in that arena and frees its temp name string.
Add a `pack` variant to IR `TypeInfo` — an ordered, interned sequence of
per-position element types (`PackInfo { elements: []const TypeId }`) — with
constructor (`packType`), structural equality + hashing, and a `pack(T0, …)`
printer. A pack is comptime-only: it lowers to flat positional args before
codegen and has no runtime layout, so `sizeOf` and `toLLVMType` bail loudly
rather than inventing a size. 5 unit tests (N=0/1/3, dedup, order/arity
distinctness, distinct-from-tuple, printer).
Also: give TypeTable an arena for the slices its constructors dupe (freed at
deinit), and add the missing `usize`/`isize` arms to `sizeOf` (a latent
non-exhaustive switch) so types.test.zig compiles and runs leak-free.
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.
Previously, type aliases (`ShaderHandle :: u32`, `Vec4 ::
Vector(4, f32)`) were resolved at three explicit call sites:
- `resolveTypeWithBindings` fallthrough (lower.zig: was 10481-83)
- Protocol method param resolution (was 11154-61)
- Protocol method return resolution (was 11169-76)
Every other `type_bridge.resolveAstType` caller silently fell into
`resolveTypeName`'s "create empty struct stub" path at the bottom,
materialising the alias name as a fresh `{Name=}` struct instead of
its target type. Symptom: the IR call signature got `{}` parameters
where the user meant `u32` etc.
This pushes the alias check inside `resolveTypeName` itself. A new
`TypeTable.aliases: ?*const std.StringHashMap(TypeId)` borrow is
loaned at `lowerRoot` from the owning Lowering. `resolveTypeName`
consults it before falling through to the stub default. Every
caller of `resolveAstType` (and its recursive helpers — `*Alias`,
`[]Alias`, `?Alias`, etc.) now picks up the same resolution.
The three pre-check sites in lower.zig collapse:
- `resolveTypeWithBindings`: the trailing alias pre-check is gone;
the comment now points at the new path.
- Protocol method param: the `Self → *void` short-circuit stays;
the alias arm is gone — the fallthrough handles it.
- Protocol method return: same shape.
Tests:
- `type_bridge.test.zig` gains `resolveAstType: TypeTable.aliases
resolves named alias` pinning the new behaviour. Demonstrates:
(1) no alias set → unknown name becomes empty struct stub (the
silent-fail shape we're fixing); (2) alias set → resolves to the
alias target; (3) compound forms (`*Alias`) recurse into
`resolveTypeName` for the inner name and pick up the alias.
224/224 example tests pass; zig build test green.
`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.
`abiCoerceParamType` had a libc-friendly heuristic: sx `string` /
`[]T` slice → `ptr` (drop the len, just pass the start pointer).
The heuristic is right for `#foreign` decls that mirror libc
signatures (`puts(const char *)`, `strlen(const char *)`); it's
wrong for sx-internal `callconv(.c)` (e.g. block trampolines) where
both sides see and exchange the full slice.
Split via a new `abiCoerceParamTypeEx(ir_ty, llvm_ty,
is_foreign_c_api)`. The old single-arg form forwards with
`is_foreign_c_api = true` so every call site that already collapses
keeps doing so. The function-decl emit at lines 1442 / 1454 now
passes `func.is_extern` — sx-internal `callconv(.c)` declarations
take the false path and preserve the slice as `{ptr, i64}` →
`[2 x i64]` via the general struct-coerce branch (true C ABI for
a 16-byte aggregate: passed in x0+x1 on AArch64).
`examples/188-block-string-arg.sx` flips green ("got: <hello>");
suite stays at 222/222. Foreign-decl call sites
(objc msg_send / JNI / direct extern calls) keep the libc
collapse — they pass `is_foreign_c_api = true` via the legacy
`abiCoerceParamType` shim.
New reflection-builtin arm in `tryLowerReflectionCall` for
`compile_error(msg)`. Resolves the string literal at lower time,
emits a focused diagnostic at the call site's span via
`self.diagnostics.addFmt(.err, ...)`, and returns a void-typed
constant so the call expression can sit in any statement position.
Three error shapes:
- Zero args → "compile_error requires a string argument".
- Non-string-literal arg → "compile_error argument must be a
string literal" (we need the message text at lower time;
runtime expressions can't be reported as compile errors).
- Valid literal → the literal text is the error message verbatim.
`examples/187-compile-error.sx` flips green (the `unresolved`
diagnostic from the lock-in commit becomes the focused
`intentional compile error from #run`). 221/221.
`#run` / post-link callback `print` output was reaching stderr via
`std.debug.print` flushes from three sites. The runtime JIT path
already writes to fd 1 (stdout) directly. Anyone redirecting one
stream saw the two halves disappear in different places.
Switches all three flush sites + the `--- build done ---` delimiter
in main.zig to `std.c.write(1, ...)` so build-time and runtime
prints share the stream the user wrote them against (they typed
the same `print(...)` at both call sites — there's no reason for
them to land on different streams). Test runner uses `2>&1` so
snapshots are unaffected; suite stays at 218/218.
Closes issue-0047.
Three additional arms that previously silently fell through to
`.s64`:
- `.null_coalesce`: `lhs ?? rhs` now returns the inner type of
lhs's optional (when applicable), else the rhs's inferred type.
Without this, `print("{}\n", iw ?? 0.0)` for `iw: ?f32`
inferred as s64 and the float value got truncated through the
pack-mono's Any boxing.
- `.field_access` struct constant: `Phys.GRAVITY` (a `Struct.CONST`
declaration) now consults `struct_const_map` for the resolved
field type. Previously the path hit only `lowerFieldAccess`'s
constant-resolution shortcut, not the AST-level `inferExprType`,
so pack-fn callers misinferred the const's type as `.s64`.
- Reflection builtins (`type_name`, `type_eq`, `has_impl`,
`field_count`, `field_index`, `field_name`, `is_flags`,
`type_of`, `field_value`): their return types live outside
`resolveBuiltin`'s table (they dispatch via
`tryLowerReflectionCall` instead). Recognise them directly in
the `inferExprType` call arm so pack-fn callers mangle the
results with the right tag (.bool for `type_eq` / `has_impl` /
`is_flags`, .string for `type_name` / `field_name`, etc).
All three holes surfaced while attempting the print/format
`..$args` migration; the fixes themselves are general
improvements and stand independently. 218/218.
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.
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.
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.
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.
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.
`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.
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-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.
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.
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.
`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.
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.
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.
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.
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.
Pack-fn bodies that index the pack via `args[<int_literal>]`
now resolve to the i-th call-site argument's lowered value
directly, propagating the call arg's concrete type instead
of the boxed `Any` that the `[]Any` slice path returns.
New plumbing in `src/ir/lower.zig`:
- `pack_arg_nodes: ?std.StringHashMap([]const *const Node)` on
Lowering. Maps a pack param name (e.g. "args") to the slice
of call-site arg AST nodes.
- `lowerComptimeCall` populates the map when the variadic
param is heterogeneous (`is_variadic AND is_comptime`, i.e.
the `..$args` form). Plain `args: ..Any` keeps the existing
`[]Any` slice path so stdlib's `format`/`print` continue
unchanged. The map is saved/restored across nested calls
mirroring `comptime_param_nodes`.
- `packArgNodeAt(ie)` returns the call-arg node when an
index_expr matches `<pack_name>[<comptime_int_literal>]`
with the index in range; null otherwise (fall through to
standard slice indexing for runtime indices or non-pack
bases).
- `lowerIndexExpr` checks `packArgNodeAt` first; on a hit it
lowers the call arg node directly. `inferExprType`'s
`index_expr` arm does the parallel check so AST-level type
inference (e.g., for field-access type checking) sees the
concrete call-arg type.
`examples/156-pack-typed-index.sx` flips from
"field 'x' not found on type 'Any'" to `7` — `args[0].x` now
resolves through the concrete `Point` type instead of Any.
Out of scope (deferred): non-literal comptime indices
(`args[$i]` where `$i` is an arbitrary comptime expression);
`$args[$i]` in type positions (step 3); per-mono mangling
(monomorphisation stays inline-only).
195/195 example tests + `zig build test` green.
`lowerComptimeCall` now scans the body for `return` statements
via `fnBodyHasReturn`. When found, it allocates a stack slot
typed to the fn's return type and installs it as
`self.inline_return_target` before lowering the body.
`lowerReturn` checks `inline_return_target` first:
- If set, it stores the coerced return value into the slot,
drains pending defers, sets `block_terminated = true`, and
returns without emitting a `ret` into the caller's basic
block.
- Otherwise it emits the standard `ret` as before.
After the body lowers, the inliner either returns the
tail-expression value (existing fast path — bodies with no
`return` skip the slot entirely) or loads the slot when
`block_terminated` is set.
Why the bug was invisible until now: `format`/`print` and
every other stdlib comptime fn use arrow form (`=> expr`) or
`#insert`-only bodies — no `return` statement, no path through
`lowerReturn`. Step 1.b of the pack feature made `..$args`
parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first
comptime-fn body to take the `return`-with-trailing-statements
path, surfacing the LLVM verifier crash.
`examples/issue-0045.sx` flips from the lock-in failure to
`42`. 194/194 example tests + `zig build test` green.
Pack-shaped impls (`impl P(...) for Closure(..$args) -> $R`) now
match concrete closure sources at xx resolution time. Concrete
impls keep their priority — pack matching only fires on a
concrete-key miss in `param_impl_map`.
New plumbing in src/ir/lower.zig:
- `PackParamImplEntry` carries the pack-shaped source TypeId plus
the pack-var and ret-var names extracted from the impl AST's
`target_type_expr`. `registerParamImpl` detects pack-shaped
sources via `pack_start != null` on the resolved closure type
and additionally registers in a new `param_impl_pack_map`
keyed by `"Proto\x00<arg_mangled>"` (no source suffix).
- `tryUserConversion` re-shapes the concrete lookup so the pack
path runs on miss. `tryPackImplMatch` walks the pack entries,
verifies the source's fixed prefix matches the impl's prefix,
binds the pack-var to the source's tail param TypeIds, binds
the ret-var (when the impl's return is generic) to the source
return, and monomorphises the convert method. Mangled name
stays keyed on the concrete source so distinct call shapes
monomorphise separately.
- `pack_bindings: ?StringHashMap([]const TypeId)` is saved/
restored around monomorphisation, mirroring `type_bindings`.
- `resolveClosureTypeWithBindings` handles the closure_type_expr
node during type resolution: when the closure carries a
`pack_name` AND `pack_bindings` has a binding for it, the
bound TypeIds are appended after the fixed prefix and the
result is a concrete (non-pack) closure type — so the impl
body's `self: Closure(..$args) -> $R` substitutes to the
concrete source closure during monomorphisation. Without an
active binding, the pack shape is preserved.
`examples/155-pack-impl-match.sx` flips from the
"no Into(Block) for cl_s32_bool__bool" lock-in diagnostic to
"pack impl match ok": one user-declared
`impl Into(Block) for Closure(..$args) -> $R` covers a
`Closure(s32, bool) -> bool` source that stdlib has no
hand-rolled impl for. Constructed Block isn't invoked
(invoke=null) — the test exercises only the matching +
monomorphisation, not the trampoline (step 5 of the plan).
Existing concrete-impl paths unchanged: 95-objc-block-noop,
96-objc-block-multi-arg, and stdlib's hand-rolled
`Into(Block) for Closure(bool) -> void` continue to pass through
the concrete map first. Same-file duplicate pack impls
diagnose at registration; cross-module visibility and
multi-pack-impl specificity stay TODOs (matching the deferred
Phase 5 work on the concrete path).
193/193 example tests + `zig build test` green.
`parseTypeExpr`'s `Closure(...)` arm now accepts a trailing
`..$name` (sigil optional) as a variadic-pack marker. Pack must
be terminal — `)` is the only token accepted after the name.
`ClosureTypeExpr` AST gains `pack_name: ?[]const u8` carrying the
identifier so later slices can name the binding.
`FunctionInfo` / `ClosureInfo` in src/ir/types.zig grow a
`pack_start: ?u32 = null` field. `Closure(..$args) -> R` interns
as `params = []`, `pack_start = Some(0)` — distinct from any
concrete `Closure(...) -> R` shape thanks to updated hash/eql
arms. New constructor pair `closureTypePack` /
`functionTypePack` keeps the existing single-shape constructors
unchanged.
`type_bridge.resolveClosureType` calls `closureTypePack` when
`pack_name != null`. The pack starts after the fixed prefix,
so `Closure(Prefix, ..$args)` resolves with `params = [Prefix]`,
`pack_start = Some(1)`.
No semantic effect yet — the signature exists in the type table
but no matching code reads `pack_start`. Step 1d wires impl
matching: `Closure(..$args) -> $R` binds against any concrete
closure source type in `tryUserConversion` / `registerParamImpl`.
`examples/154-pack-type-rep.sx` flips from rejecting-with-error
to positive parse smoke (prints "pack type rep ok").
192/192 example tests + `zig build test` green.
Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.
New design (strict mode):
1. Stdlib's modules/std/objc_block.sx hand-rolls
`__block_invoke_void` + `Into(Block) for Closure() -> void` and
the same pair for `Closure(bool) -> void` (restored from M5.A.2).
These are readable reference implementations of the bridge ABI.
2. The compiler intercept fires NO synthesis — instead, when
`tryUserConversion` can't find a reachable `Into(Block)` impl for
the closure's signature, it emits a focused diagnostic:
"no `Into(Block) for <Closure-sig>` impl — add a per-signature
`__block_invoke_<sig>` trampoline + Into impl alongside the
existing ones in modules/std/objc_block.sx, or declare it in
your own code"
3. Per-signature declarations live in stdlib (for common signatures)
or in user code (for app-specific ones). 96-objc-block-multi-arg
now demonstrates the user-side pattern in-file — it declares its
own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
*void) -> void` impl alongside its main().
Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
not silent fallthrough.
Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.
190/190 example tests pass; chess on iOS-sim green.
`xx closure : Block` casts now bypass the user-space Into(Block)
protocol path entirely. The compiler intercepts in
`tryUserConversion` BEFORE the Into lookup, detects when src is
`Closure(...)` and dst is `Block`, and emits:
1. A C-ABI trampoline `__block_invoke_<sig>` (deduped per closure
signature via `block_invoke_trampolines` map). Body matches the
existing hand-rolled `__block_invoke_void` exactly: load
block_self struct, extract sx_env (field 5) + sx_fn (field 6),
call sx_fn(__sx_default_context, sx_env, ...user_args), return.
2. Inline Block-struct construction at the cast site:
`Block { isa = &_NSConcreteStackBlock, flags=0, reserved=0,
invoke = &__block_invoke_<sig>,
descriptor = &__sx_block_descriptor,
sx_env = closure.env, sx_fn = closure.fn_ptr }`
Signature mangling: compact codes — `v` void, `b` bool, `i` s32,
`q` s64, `f` f32, `d` f64, `c/C/s/S/I/Q` for other ints, `p` for
pointers/aggregates that lower to a machine word. Return first,
then params underscore-joined. `Closure() -> void` mangles to `v`;
`Closure(bool) -> void` mangles to `v_b`.
Loud failures at the cast site:
- `Block` struct missing → "requires #import \"modules/std/objc_block.sx\";"
- `_NSConcreteStackBlock` extern missing → same diagnostic.
- `__sx_block_descriptor` global missing → same.
- `__sx_default_context` missing inside the trampoline emitter →
compiler-bug diagnostic (the scan pass should always register it).
The existing hand-rolled stdlib impls (`__block_invoke_void`,
`__block_invoke_bool`, the two `Into(Block) for Closure(...)`
impls) are now redundant — the compiler-synthesised trampoline
takes over via the intercept. Next commit (M5.A.2) removes them.
95-objc-block-noop continues to pass; IR shows `__block_invoke_v`
(the synthesised name) replacing the hand-rolled
`__block_invoke_void` at the cast site. 189/189 example tests
pass; chess on iOS-sim green.
emitObjcDefinedClassDeallocImp now walks the class's #property fields
BEFORE freeing the state struct. For each:
- assign → no-op (primitives, no ARC traffic).
- strong → val = load field; objc_release(val).
- copy → same as strong (the stored value is a +1 retained copy
produced by the setter's [val copy]; we release it here).
- weak → objc_destroyWeak(&field) — unregisters the slot from
libobjc's side-table so the runtime stops tracking it.
Order matters: property releases happen BEFORE freeing the state
struct (which would invalidate the pointers we need to read), which
happens BEFORE [super dealloc] (which eventually frees the Obj-C
instance's own memory). The full sequence is now:
%state = object_getIvar(self, __sx_state_ivar)
// M4.B (this commit):
for each strong/copy property P:
val = load struct_gep(state, P.idx); objc_release(val)
for each weak property P:
objc_destroyWeak(struct_gep(state, P.idx))
// M4.0c (already shipped):
allocator = load struct_gep(state, 0)
allocator.dealloc(state)
object_setIvar(self, ivar, null)
// M1.2 A.6:
[super dealloc] // → objc_msgSendSuper2
ffi-objc-arc-02-strong-property now passes: child held by parent's
strong property gets released when parent deallocates, refcount → 0,
child deallocates, both states freed via tracker. Balanced 2/2.
189/189 example tests pass; chess on iOS-sim green. M4 complete.
emitObjcDefinedPropertyGetter dispatches on objcPropertyKind. The
strong/copy/assign paths keep their bare load. The weak path:
retained = objc_loadWeakRetained(field_addr)
autoreleased = objc_autorelease(retained)
return autoreleased
`objc_loadWeakRetained` does the race-safe upgrade via libobjc's
side-table: if the target has deinitialized (or is mid-dealloc on
another thread), returns null; otherwise returns the target with
refcount bumped (+1 retained, transferred to caller).
`objc_autorelease` drops the +1 into the current pool so the
caller doesn't need to manually balance — matches Apple's auto-nil
weak-getter contract.
The bare-load weak path (still in place pre-M4.B-getter) worked
for the single-threaded test scenario because the runtime nils the
slot before the load happens. The load-retained version covers the
multi-threaded "between load and use, target deinit's" race that
silent bare-load can't.
189/189 example tests pass; chess on iOS-sim green.
emitObjcDefinedPropertySetter now dispatches on objcPropertyKind to
emit the right runtime ops per Apple's ARC contract:
- assign → bare store (primitives, explicitly opted-out object slots).
- strong → load old; objc_retain(new); store new; objc_release(old).
Apple's runtime treats release(NULL) as a safe no-op, so
no explicit null-check on the old value.
- weak → objc_storeWeak(field_addr, val) — handles first-store
(init) and re-store (destroy + init) atomically. Registers
the slot with libobjc's side-table; the runtime auto-nils
it when the target deallocates.
- copy → [val copy] (sends `copy` selector — returns retained per
the NSCopying contract); load old; store the copied
instance; release old.
Side-effect on the weak path: even with the bare-load getter still in
place (loaded directly from the slot), weak reads work because Apple's
runtime side-table-nils the slot at target dealloc. The getter
improvement via objc_loadWeakRetained is the next commit and is
needed for race-safe reads (between load and use, the target could
deinit on another thread); for the single-threaded test scenarios
the bare load is sufficient.
ffi-objc-arc-02-strong-property advances from "child dealloc'd at
midpoint" to "unbalanced; alloc=2 dealloc=1" — strong setter now
retains, but the M4.B-dealloc cleanup hasn't landed so the child
held by the property isn't released when the parent deallocates.
Final commit (M4.B dealloc) closes the loop.
ffi-objc-arc-03-weak-property turns fully green: storeWeak +
auto-nil side-table do the work.
189/189 example tests pass; chess on iOS-sim green.
Three pieces, no behavior change yet:
1. `ObjcPropertyKind` enum (strong/weak/copy/assign) + `objcPropertyKind`
helper in lower.zig. Reads `field.property_modifiers`, applies the
default rule (`*<ObjC-class>` → strong; primitives → assign), and
emits loud diagnostics for the silent-error budget:
- unknown modifier name (typo) → "expected one of: strong, weak, copy, ..."
- conflicting modifiers (e.g. `strong,weak`) → "mutually exclusive"
- `weak` on non-object slot → "requires a pointer-to-Obj-C-class type"
- `copy` on non-object slot → same
- `strong` (default or explicit) on `*void` → "ambiguous: specify
#property(strong|weak|copy|assign) explicitly"
Called from `emitObjcDefinedClassPropertyImps` for validation; the
returned kind isn't wired into setter/getter/dealloc yet — that's
the next three commits.
2. `ensureArcRuntimeDecls` lazily declares libobjc's ARC helpers:
objc_retain, objc_release, objc_storeWeak, objc_loadWeakRetained,
objc_initWeak, objc_destroyWeak. Uses the existing
`ensureCRuntimeDecl` pattern; idempotent.
3. Fix existing NSObject method names in std/objc.sx — `isEqual_`,
`isKindOfClass_`, `respondsToSelector_` had trailing underscores
that the selector mangling turned into double-colon selectors
(`isEqual::`). Removed the trailing underscore so the selectors
come out as `isEqual:`, `isKindOfClass:`, `respondsToSelector:`
as Apple's runtime expects.
4. Two xfail regression tests:
- ffi-objc-arc-02-strong-property: assigns child to parent's strong
property, releases the original child reference. Midpoint check:
child's dealloc should NOT have fired (strong setter retained).
Pre-M4.B-setter: child dealloc fires immediately → "FAIL: child
dealloc'd at midpoint" snapshot. Exit code 1.
- ffi-objc-arc-03-weak-property: assigns target to holder's weak
property, releases target. Reads holder.target → should be null
(auto-niled). Pre-M4.B-getter/setter: reads stale pointer →
"FAIL: weak property didn't auto-nil" snapshot.
These will turn green as M4.B setter (commit 2), getter (commit 3),
and dealloc-cleanup (commit 4) land. Each subsequent commit updates
the snapshot to reflect the now-passing output.
189/189 example tests pass; chess on iOS-sim green.
The synthesized -dealloc IMP now loads `state->__sx_allocator` (the
slot captured at +alloc time by M4.0a + M4.0b) and dispatches
`allocator.dealloc(state)` through the inline-protocol fn-ptr at
slot 2. Old behaviour was `free(state)` — went straight to libc,
ignoring whatever allocator the instance was constructed with.
After this commit, the per-instance allocator design from M1.2 A.5
is finally end-to-end correct:
push Context.{ allocator = arena } {
f := SxFoo.alloc(); ← arena.alloc(STATE_SIZE) + capture
// ... use f ...
}
// refcount → 0 ⇒ -dealloc:
// load state->__sx_allocator = arena
// arena.dealloc(state) ← same allocator round-trips
TrackingAllocator now sees the alloc/dealloc pair; the deferred M1.2
A.5 work is done. Closes the loop on M4.0.
The dealloc IMP passes `__sx_default_context` as the implicit __sx_ctx
when invoking the dealloc fn-ptr — the IMP itself has no caller-side
ctx (it's called by Apple's runtime at refcount-zero), and the
default GPA is the right baseline for any nested allocations the
dealloc body might perform.
Each compiler-internal lookup that "can't fail" (Context type,
__sx_default_context global) emits a loud diagnostic instead of
silent fall-through, per the silent-error budget.
184/184 example tests pass; chess on iOS-sim green.
Two converging paths now allocate the state struct via the protocol's
allocator instead of raw malloc:
(1) sx-side `Cls.alloc()`: compiler intercepts in `lowerObjcStaticCall`
when the receiver is a sx-defined `#objc_class` and the method is
the niladic `alloc`. Emits the inline alloc-and-init sequence
using the caller's `current_ctx_ref` as the context — so
`push Context.{ allocator = my_arena } { let f := SxFoo.alloc(); }`
honors `my_arena` end-to-end. The msgSend dispatch is bypassed
entirely for this case.
(2) Obj-C-runtime `[Cls alloc]` (Info.plist principal class, NSCoder,
UIKit reflection): the synthesized `+alloc` IMP shim reads
`__sx_default_context.allocator` and calls into the same shared
helper. The IMP has `has_implicit_ctx = false` and runs with no
caller-side context — the default GPA is the right policy choice
for "everything Apple's runtime instantiates".
Shared helper `emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr)`
does the work: `class_createInstance` → `ctx.allocator.alloc(STATE_SIZE)`
via the inline-protocol fn-ptr → memset 0 → store allocator at
state[0] (the M4.0a slot, captured for -dealloc's later use) →
`object_setIvar(instance, __sx_state_ivar, state)`. Loud failures
on missing globals via the diagnostics system.
The sx-side interception must explicitly bitcast the
`class_createInstance` result from `*void` to the method's declared
return type (`*<Cls>` or `?*<Cls>`). lowerVarDecl reads the Ref's IR
type when no type annotation is present, and coerceToType is a
no-op for ptr→ptr — without the bitcast, `let f := SxFoo.alloc();`
binds `f` at `*void` and downstream `f.class` / `f.method()` fails
to find anything.
-dealloc still uses `free(state)` (M4.0c rewrites it). 184/184 tests
pass; chess on iOS-sim green.
State struct for an sx-defined `#objc_class` now leads with an
Allocator field at index 0 — captured at +alloc time, read by
-dealloc to free the state through the same allocator. User fields
shift to index 1+; the existing by-name lookups in
emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer
naturally resolve them at the new indices.
This step is the layout change only; the +alloc IMP still mallocs
(M4.0b will rewrite it to thread context.allocator through), and
-dealloc still uses free() (M4.0c). The field is allocated but
uninitialised; nobody reads it yet.
Storage type comes from `Context.fields[0].ty` via the new
`objcStateAllocatorType` helper — same Allocator value-shape the
implicit context machinery has used all along. If Context isn't
registered (early-init paths), the helper falls back to omitting
the field rather than synthesising a half-broken layout.
IR snapshot for 142-objc-class-method-lowering updated to reflect
the new struct shape and the +24-byte state allocation. Chess on
iOS-sim green; 184/184 example tests pass.