A null type node means a caller reached type resolution without a type
node. Every current caller passes a non-optional node or handles the
"no type" case itself (returning .void), so a null here is a caller bug;
.s64 silently fabricated an 8-byte int. Return the .unresolved sentinel
so it surfaces (trips the sizeOf/toLLVMType panic at codegen).
The only thing relying on the old behavior was a unit test asserting
null => .s64 -- i.e. a test pinning the silent default. Updated it to
pin .unresolved.
A non-type AST node reaching type resolution is a caller bug; returning a
plausible .s64 silently fabricated an 8-byte int. Return the .unresolved
sentinel so it surfaces (and trips the sizeOf/toLLVMType panic if it ever
reaches codegen). The stderr breadcrumb stays. No test exercised this arm
(suite unchanged), so nothing was relying on the fabricated s64.
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).
`..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.
Pack/tuple spread now parses in tuple-value `(..xs)` / `(..xs.field)`,
tuple-type `(..F(Ts))` / `(..F(Ts.Arg))`, call-arg `f(..xs)` (already),
and closure-sig `Closure(..Ts)` / `Closure(..sources.T)` positions.
Design: the uniform spread node is the existing `spread_expr` (its
operand sub-expression carries the projection `xs.field` and
type-application `F(Ts)` shapes) rather than a new PackExpansion node —
call-arg slice-spread (`..arr`) and pack-spread (`..pack`) are
syntactically identical, so they must share one node, and spread_expr
already serves it with working slice lowering. Closure-sig packs gain
`ClosureTypeExpr.pack_projection` alongside the existing `pack_name`.
Parser-only; sema/lowering land in Phase 2. 6 new parser unit tests +
examples/probes/pack-expansion-parses.sx. Build + 225-suite green.
`..xs: Protocol` (a bare protocol, no `[]`, no `$`) on a variadic
parameter now parses to `ast.Param.is_pack = true` — a heterogeneous
protocol-constrained pack, distinct from a slice variadic
(`..xs: []T`, is_pack=false) and the comptime type-pack (`..$args`,
is_comptime=true). Parser-only: sema/lowering for the pack form land in
Phase 2; existing forms are unaffected (zero examples used a bare
non-slice variadic annotation). Adds three parser unit tests and
examples/probes/pack-param-parses.sx.
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.
Adds RenderStyle (compact/extended), renderExtended/renderExtendedOne
producing the locked Rust-style format (header, --> location arrow, blank
bar, numbered code excerpt, caret line), and dispatches render() through
a render_style field on DiagnosticList. Old render body extracted as
renderCompact and kept as the default so existing snapshots stay
unchanged — F0.3 flips the default.
renderExtendedOne builds on F0.1's extractContext. Helpers digitCount
(line-number column width) and writeRepeated (no writeByteNTimes in
modern std.Io.Writer) are file-private. Line-number column has a min
width of 2 to match Rust's visual style.
7 new tests cover single-line span with carets, warning prefix,
span-less header, triple-digit line widening the column, empty-span
single caret, multi-line span with per-line carets, and the compact-
default regression. All 15 errors tests pass via `zig test
src/errors.test.zig`; 224 regression tests green.
Surfaced gotcha: zig build test doesn't currently exercise src/*.test.zig
files because src/root.zig lacks refAllDecls; adding it exposes
pre-existing breakage in src/ir/lower.test.zig and src/ir/types.zig.
Reverted that addition — out of scope for the lang workstream; unit-test
verification uses direct zig test for now.
Adds LineInfo, ContextLines, and extractContext(allocator, source, span) to
errors.zig — a pure utility that returns the source lines covered by a span
plus columns for caret rendering. Prereq for F0.2's new render path which
will produce Rust-style multi-line diagnostics with code excerpts.
8 unit tests cover the boundary cases: single-line span, multi-line spans
(1 and 2 newlines crossed), span on an empty line, span at end-of-file
without trailing newline, empty source, and offsets beyond source.len
(clamping).
No render surface change yet; F0.2 wires this into a new render mode kept
behind a RenderStyle flag so old gcc-style output remains available during
the transition.
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.
The special-case `return self.fail("legacy variadic syntax ...")`
in `parseParams` is gone. `parseTypeExpr` already errors naturally
on a leading `..` (now reported as "expected type name"), which
is enough — the language-level cutover happened in the previous
commit; no need for the parser to keep a migration breadcrumb.
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.
Parser hard-rejects the legacy `name: ..T` form with a one-line
migration message pointing at the new `..name: []T` shape. The
leading-`..` form is the one the lowering paths
(`resolveParamType` / `packVariadicCallArgs`) treat as canonical
post-issue-0049; leaving both forms accepted invited the same
class of cross-module emit crashes any time a `..T`-form decl in
stdlib crossed an import boundary.
`specs.md` updated alongside: the Variadic Functions section now
documents `..name: []T` as the surface form, with notes on
homogeneous vs `[]Any` boxing and the `..` spread at call sites.
Inline references to `args: ..Any` in §7 and §8 refreshed.
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.
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.
`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.