`expr catch [e] BODY` consumes a failable's error inline. Pure-failable slice
(value-carrying `-> (T, !)` catch deferred to E2's tuple ABI).
- lowerExpr `.catch_expr` -> lowerCatch; inferExprType `.catch_expr` ->
operand's success type (void for pure-failable).
- lowerCatch: operand must be failable (else "catch requires a failable
expression"); pure-failable LHS only (value-carrying bails to E2). Eval
operand -> err tag; condBr to handle (error) / merge (success). In handle:
child scope binds `e` to the tag (typed as the error set), lower body
(block or expr); if the body didn't diverge, br merge. Result is void.
`catch` needs no failable enclosing function — it handles the error locally.
- All four body forms work: block, no-binding `catch { }`, bare-expr, and
the match-body `catch e == { case ... }`. Re-raise (`raise e`) and diverging
bodies (`return`) rely on E1.3 / E1.4c.
Also: lowerMatch now supports error-set subjects — `case .X` resolves to the
global tag id (was the arm index, dispatching wrong), and the switch operand
is the error-set value (its u32 tag) directly rather than via enumTag. This
is what the catch match-body form (and a plain `if e == { case .X }`) needs.
Tests: examples/226-catch.sx (block / no-binding / match-body / re-raise /
diverging body / success-skip; exit 18), examples/227-catch-rejections.sx
(operand-not-failable; exit 1). Gates: zig build, zig build test,
265/265 examples.
A match (`if subject == { case ... }`) whose arms all diverge (each
`return`s / `raise`s) failed LLVM verification with a `void` phi plus
"Terminator found in the middle of a basic block". Two causes in lowerMatch:
- The value-arm path did `lowerBlockValue(arm.body) orelse constInt(0, …)`,
emitting the fallback `const` into a block the body had ALREADY terminated
(a diverging arm), so `currentBlockHasTerminator()` then saw the const (not
the `ret`) and emitted a `br merge` after the terminator. Fix: materialize
the fallback value + branch only when the block hasn't terminated.
- A fully-diverging match infers `result_type == .noreturn` yet still built a
value-merge phi. Fix: `has_value_merge` excludes `.noreturn`, so such a
match builds no phi; its arms terminate and the merge block is unreachable.
Also: inferMatchResultType now skips `.noreturn` arms (a diverging arm doesn't
decide the result type) and reports `.noreturn` only when EVERY arm diverges —
so a mixed match (some arms yield values, some diverge) infers the value type.
This unblocks ERR E1.5's `catch` match-body form (`x catch e == { case .A:
return …; else: raise e; }`), which desugars to an all-diverging match.
Regression: examples/225-match-diverging-arms.sx (all-diverging + mixed,
exit 134). Gates: zig build, zig build test, 263/263 examples.
Type the divergence shapes as `noreturn` so a `catch` body that diverges
(E1.5) unifies with the failable's success type. The plan's original
"E1.4b", renumbered E1.4c (the SCC slice took the "E1.4b" label).
- inferExprType: `return` / `raise` / `break` / `continue` -> .noreturn
(removed `.return_stmt` from the statements-are-`.void` group)
- if-else unification: a `.noreturn` branch yields the other branch's type;
both diverging -> `.noreturn`
- block-ending-in-divergence propagates `.noreturn` (existing block arm)
- calls to `-> noreturn` already type via Function.ret (verified)
- made inferExprType pub for the unit test
Scope: the essential divergence shapes. Deferred `unreachable` (not a
keyword in sx — a separate feature, no current consumer) and infinite-loop
`noreturn` detection (rare). No observable consumer until E1.5's catch body,
so validated by a unit test, not an example.
Tests: unit test `E1.4c noreturn typing` in lower.test.zig (each shape ->
noreturn; block propagation; if-else unification). Gates: zig build,
zig build test, 262/262 examples (no new examples).
The type-convergence side of E1.4 (the SCC slice). A bare `-> !` function's
error set is now converged whole-program from its literal raises plus the
sets of the pure-failable functions it `try`s.
- convergeInferredErrorSets: a pre-lowering fix-point pass (lowerRoot Pass
1d, after scanDecls / before body lowering) that walks each top-level
bare-`!` function's body AST (collectErrorSites, stopping at nested-fn
boundaries) for literal `raise error.X` tags + pure `try g()` edges, then
unions each set with its edges' sets until stable. Stored in a side map
`inferred_error_sets` (fn name -> sorted []u32) — sidesteps the name-only
error-set interning collision (the shared `!` placeholder stays empty).
- lowerTry widening: a named caller `try`-ing a bare-`!` callee now checks
the callee's converged set (previously a false-negative — the empty
placeholder was trivially a subset). Factored diagTagsNotInSet out of
checkErrorSetSubset.
- empty-inferred warning: a top-level non-main bare-`!` function with an
empty converged set warns. Not user-visible yet (the compile driver
renders diagnostics only on failure — a LANG follow-up), so unit-tested
on the DiagnosticList.
- corrected two now-stale bail messages (failable-`or` -> E2.4;
value-carrying `try` -> E2).
Deferred to E2.4: failable-`or` chains / value-terminators (and `try`
fallback routing) — gated on the value-carrying tuple ABI.
Tests: examples/223-inferred-error-sets.sx (transitive convergence +
widening passes, exit 7), examples/224-inferred-widening-reject.sx
(transitive widening rejection, exit 1), unit test in lower.test.zig.
Gates: zig build, zig build test, 262/262 examples.
`raise EXPR` now terminates a failable function via the error channel.
Scope (Option 2): full raise sema checks + lowering for the pure-failable
shape (`-> !` / `-> !Named`); the value-carrying `-> (T..., !)` shape bails
loudly, deferred to E2's error-channel tuple ABI.
- lowerStmt + tryLowerAsExpr: `.raise_stmt` -> lowerRaise (also routes a
raise that is a block's last statement, which previously hit unknown_expr)
- lowerRaise: failable-context check (effectiveReturnType + errorChannelOf);
literal membership via lowerErrorTagLiteral; variable form subset-checked
via checkErrorSetSubset; pure-failable emits ret(tag)
- lowerErrorTagLiteral skips membership for the bare-`!` inferred placeholder
- plain `return;` in a pure-failable fn emits ret(0) (success / no error)
- parser: in_defer_body flag rejects `raise` inside a `defer` body
Tests: examples/219-raise.sx (positive, exit 8),
examples/220-raise-rejections.sx (3 sema rejections, exit 1), inline parser
test for raise-in-defer. Gates: zig build, zig build test, 258/258 examples.
Completes E1.1. All in ir/lower.zig (the IR layer, per slice 1's finding).
- lowerFieldAccess intercepts `error.X` (parsed as field_access(identifier
"error", X)) → lowerErrorTagLiteral: interns the tag; when target_type is a
named error set, types the value as that set and validates X ∈ set (out-of-set
→ diagnostic); otherwise emits the raw u32 global tag id (the spec's
context-free default — not a silent guess).
- tryLowerErrorSetEquality (early branch in lowerBinaryOp) + errorSetTypeOf /
isErrorTagLiteralNode: an error-set value or `error.X` literal forces the other
operand to be one too, else a diagnostic ("compares only with an error.X tag or
another error-set value; coerce with `xx`"). Both sides lower under the set type
as context (error.X resolves + membership-checks); two bare tag literals with no
context compare as global u32 ids. Handles both operand orders.
First ERR examples (end-to-end): 217-error-sets.sx (declared set + error.X +
== true/false + u32 coercion → "error-set result: 25", exit 25) and
218-error-set-typing.sx (out-of-set literal + tag-vs-raw-int → 2 diagnostics).
Failable `!`/`!Named` signatures and raise/try/catch/onfail semantics remain
(E1.2+). zig build, zig build test, and 256/256 examples green.
First sema/types step. Implemented in the IR layer (ir/types.zig +
type_bridge.zig + lower.zig), NOT src/sema.zig — lowering doesn't consume
sema; the frontend Type is LSP-only. Mirrors how enums are handled.
- ir/types.zig: new `.error_set` TypeInfo kind (ErrorSetInfo {name, tags:
[]u32}; identity = name, like enum) with a u32 runtime layout (size/align
4, LLVM i32) per the locked error-slot ABI. New TagRegistry on TypeTable
(global tag pool: name -> u32, monotonic, id 0 reserved for "no error").
internTag/getTagName/errorSetType helpers; `.error_set` arms in all 7
exhaustive switches + findByName.
- emit_llvm: toLLVMTypeInfo -> i32. print: writeType -> set name.
- type_bridge: resolveInlineErrorSet (mirrors resolveInlineUnion) +
.error_set_decl arm.
- lower.zig: registerErrorSetDecl (rejects empty `error { }` with a
diagnostic) wired into both top-level decl switches + the block-local one.
- tests: ir/types.test (TagRegistry 0-reserved + identity; errorSetType u32
layout + named display + dedup; sorted storage) and ir/type_bridge.test
(decl -> type + tag interning + re-resolve dedup).
End-to-end: `Foo :: error { A, B }` + main compiles + runs (exit 0) — first
ERR syntax to survive the full pipeline; empty set rejects with a diagnostic.
Inferred bare `!`, error.X value, and == typing deferred to slice 2 / E1.2.
zig build, zig build test, and 254/254 examples green.
The check only caught `for xs: (*m)` loop captures; passing a `*T`
parameter or any pointer local where `T` is expected still slipped through
to the LLVM verifier. Key the diagnostic on the lowered argument's type
instead of the capture, so a `*Move` parameter forwarded into a by-value
parameter is reported the same way. Ref-capture wording is preserved.
Add example 216 (pointer-parameter case) alongside 215 (loop capture).
`for xs: (*m)` binds `m` to a `*T`. Passing it directly to a parameter
that wants `T` produced invalid IR that only LLVM's verifier caught, with
the opaque 'Call parameter type does not match function signature'. Detect
it at the call site and emit a clear error with a fix-it suggesting `m.*`.
Add example 215 + expected output as a regression test.
The collection for-loop now iterates a List(T)-like struct ({ items: [*]T, len, … }) — and a *List — by viewing it as items[0..len]. So 'for legal: (m)' / 'for pieces: (*p)' work like iterating a slice, with by-ref captures writing back into the backing.
fixupMethodReceiver also derefs a *T receiver when the method takes T by value, so a 'for xs: (*x)' capture can call value-self methods (x.method()). Regression: examples/for-list.sx.
(*x) binds x to a pointer into the collection (index_gep) instead of a per-element value copy: passing it on (e.g. to a *T param) is zero-copy and mutations write back. In a value position x auto-derefs — a binary-op operand loads the element, a pointer-typed slot keeps the pointer, and an 'if x == {...}' match derefs the pointee for its tag/payload. Arrays GEP through their storage so writes hit the original. Regression test: examples/for-by-ref-capture.sx.
The arithmetic-only check from the previous commit shared a hole with the
comparison and bitwise/shift ops: lowerBinaryOp derives the result type
from the LHS, so `s64 < string` fed mismatched types to `icmp` (LLVM
verifier failure) and `s64 & string` reinterpreted the string's bytes.
Add isOrderingOperand (numeric / enum / pointer / bool / vector) and
isBitwiseOperand (integer / enum / bool / vector), and route `< <= > >=`
and `& | ^ << >>` through them alongside the existing arithmetic check, all
sharing one diagnostic + placeholder-sentinel path. Flags-enum bitwise
(`.read | .write`, `perm & .read`), enum/pointer comparison, and int
literals stay legal (50-smoke unaffected).
Equality `== / !=` is deliberately left unchecked — its path is heavily
special-cased (str_eq, Any unbox, optional == null); folding a check in
without regressing those is a separate change, noted in the issue.
Regression test renamed arith→binop and broadened to cover `+ * < & <<`
against a string operand: examples/214-binop-operand-type-check.sx.
lowerBinaryOp derived the result type from the LHS alone and emitted
add/sub/mul/div/mod without checking the RHS, so `s64 + string` lowered
as `add : s64` and reinterpreted the string's bytes — printing garbage
instead of erroring.
Add isArithOperand (int / float / vector / pointer, plus custom int
widths) and, for `+ - * / %`, diagnose `cannot apply '<op>' to operands
of type '<lhs>' and '<rhs>'` and return a placeholder sentinel instead of
the corrupting op. `.unresolved` operands pass through so a type we
couldn't infer is never falsely rejected; the existing optional-unwrap
and int×float promotion are accounted for before the check.
Ordering (`< <= > >=`) and bitwise/shift (`& | ^ << >>`) ops share the
same LHS-derived-type hole and are left as a noted follow-up in the issue.
Regression: examples/214-arith-operand-type-check.sx (s64 + string, and
non-numeric LHS string * s64).
The full canonical `map` now compiles and runs (examples/213 → 42):
map :: (mapper: Closure(..sources.T) -> $R, ..sources: VL) -> VL($R)
Final piece: infer a pack-fn's generic return `$R` from a closure-typed
prefix param's lowered return type.
- collectGenericNames descends into closure_type_expr (params + return),
so `$R` in `Closure(..) -> $R` registers as a function type-param.
- matchTypeParam/extractTypeParam descend into closures: `$R` is extracted
from the lowered mapper's closure `.ret`.
- lowerPackFnCall infers type-param bindings from the lowered prefix args,
folds them into the mangle, and threads them into monomorphizePackFn,
which installs self.type_bindings for return-type resolution + body
lowering (`-> VL($R)` ⇒ VL(s64); `Combined($R, ..)` ⇒ Combined(s64, ..)).
s64-elimination follow-through:
- An unbound generic `$R` resolves to `.unresolved` in resolveTypeWithBindings
rather than fabricating an empty-struct stub (`R{}`).
- Lambda return-type inference skips an `.unresolved` target-closure ret and
infers from the body, so the concrete return drives `$R`.
- The `.unresolved` codegen tripwire then caught a latent bug: a generic-struct
source impl (`impl VL($R) for Combined($R, ..$Ts)`) was declaring its template
method `Combined.get` (`-> $R`) as a standalone IR function. Fixed: a
generic-struct source registers methods as TEMPLATES only (findable in
fn_ast_map for per-instance monomorphization via createProtocolThunk), never
declareFunction'd.
Feature 1 (heterogeneous variadic packs) all six phases complete.
248 examples + all unit tests green.
Two fixes, root-caused from xx Combined -> VL(s64) trapping:
- instantiateGenericStruct binds the template name to the concrete instance
(tb.put(tmpl.name, id)), so an impl method self: *Combined resolves self.field
to the instance (Combined__s64_s64), not the 0-field generic stub. This was a
general pre-existing bug: self.x on ANY generic-struct impl method failed.
- createProtocolThunk monomorphizes the template method for a generic-struct
instance (Combined.get -> Combined__s64_s64.get with the instance bindings),
so the erasure vtable dispatches instead of hitting an unreachable thunk.
xx c on a generic Combined now dispatches correctly (examples/212 -> 99).
247 examples + unit green.
lowerPackFnCall lowered the runtime prefix args with no target_type, so a
lambda arg (mapper: Closure(...) -> ...) could not infer its param types.
Now set target_type to the param type while lowering each prefix arg. With
the existing value-projection call-arg spread, mapper(..sources.get) works:
the lambda is contextually typed and the projected values spread into the
call. examples/211 ((a,b)=>a+b over two sources -> 42). 246 + unit green.
lowerTupleLiteral now coerces/erases each spliced spread element to the
contextual target tuple field type (computed even when a spread is present,
indexed by output position). New coerceOrErase: protocol target -> xx-erase
via buildProtocolErasure, else coerceToType. So c.sources = (..sources) on a
(..VL(Ts)) field erases each concrete pack element to its VL(Ti) slot.
examples/210 (build(IntCell, StrCell) -> 10 hi). 245 examples + unit green.
Parser now accepts a `..` spread in a parameterized-type arg list; in
instantiateGenericStruct a spread arg bound to the variadic type-param expands
via packTypeElems (so `..sources.T` projects each source pack element protocol
type-arg into ..$Ts). `Combined(s64, ..sources.T)` for a VL(s64) source
instantiates Combined(s64, s64). examples/209 (with explicit per-element xx
erase). 244 examples + unit green.
Next: (..sources) whole-pack materialization with per-element erasure into the
protocol-typed field (c.sources = (..sources) currently segfaults).
Two fixes:
- Element assignment `t.0 = v` (the known Phase-4.2 gap): the lvalue path
looked the element up by NAME via getStructFields, never matched a tuple
(positional), and left field_ty .unresolved -> ptr(.unresolved) -> codegen
panic. Added a tuple branch to the field-assignment lowering that indexes by
position (numeric) or name (tup.names), mirroring the read path. Fixes
`c.sources.0 = v` on a generic-instance pack field too.
- Named tuples: the parser dropped captured field names for a tuple TYPE
`(x: T, y: U)` (passed field_names=null), and resolveTupleTypeWithBindings
also nulled them. Both now preserve names (synthesizing _<i> for any unnamed
slot), so `t.x` reads/writes by name and `.0` by position.
examples/208. 243 examples + unit green.
packTypeElems now handles a parameterized spread operand F(Ts): for each pack
element T_i it temporarily binds the pack name to T_i and resolves F(T_i),
yielding (VL(T0), VL(T1), ...). Combined with parameterized-protocol value
types, the canonical Combined struct field sources: (..VL(Ts)) now resolves to
a tuple of real protocol values.
End-to-end (examples/207): instantiate Combined(s64, s64, string), whole-store
c.sources = (xx IntCell, xx StrCell), and per-element dispatch c.sources.0.get()
/ c.sources.1.get() all work. 242 examples + unit green.
VL(s64) used as a value/field type resolved to a 0-field stub (size 0); a
plain protocol was already a 16-byte {ctx,vtable} value. New
instantiateParamProtocol materializes a parameterized protocol per
instantiation: a 16-byte protocol value (is_protocol), protocol_decl_map
methods resolved under the type-arg binding (get -> T becomes get -> s64 for
VL(s64)), a vtable struct, and the type-arg binding recorded for projection.
Hooked into resolveParameterizedWithBindings before the empty-struct fallback.
xx-erasing a conforming struct into VL(s64)/VL(string) + method dispatch now
works (examples/206). This is the keystone for the canonical Combined field
(..VL(Ts)). 241 examples + unit green.
A generic struct can take a pack type-param ..$Ts: []Type that binds the
remaining type args as a sequence, and a pack-shaped tuple field (..$Ts)
resolves to a tuple of those per-position types.
- parser/ast: accept a leading .. on a struct generic param; StructTypeParam
gains is_variadic.
- registration: TemplateParam carries is_variadic (and is a type param).
- instantiateGenericStruct: a variadic type-param consumes the remaining args
into pack_bindings + pack_arg_types (mangled into the name); restored after.
- resolveTypeWithBindings: a tuple-literal-as-type containing a pack spread
(e.g. (..$Ts)) expands via packTypeElems.
Instantiate + correct per-position field types + whole-tuple store + element
read all work (examples/205). Not yet: protocol-applied field (..F(Ts)) (the
canonical (..VL(Ts)) shape) and nested element assignment b.pair.0 = v.
240 examples + unit green.
xx args with a slice target now bridges a comptime pack to a runtime slice:
[]Any boxes each element to Any; []P xx-erases each to the protocol (reusing
the slice-of-protocol erasure from 0052). New lowerPackToSlice; the unary-op
arm intercepts xx <pack> before the pack-as-value diagnostic. This is the
working forward to a runtime []Any/[]P helper -- log_count(xx args) -> 3 --
so the 2.7 pack-as-value diagnostics now suggest xx <name> for the call case.
examples/204-pack-xx-to-slice.sx (both []Any and []P paths); 203 help text
updated. issue 0053 FIXED. 239 examples + unit green.
Using a bare pack name where a runtime value is required was silent garbage
(f(xs)/return xs produced a stray pointer). Now a clear, context-tailored
compile error: isPackName + diagPackAsValue, caught at lowerVarDecl (storage),
lowerReturn (return), lowerFor (iterate), and an identifier-arm catch-all for
call/other. Storage binds a placeholder so there is no cascade error.
Suggestions point at WORKING fixes -- materialize (..xs), or declare the slice
form ..xs: []P for runtime use. The plan category-B "spread ..xs" is broken
(spreading a comptime pack into a []Any param crashes the LLVM verifier; filed
issue 0053), so the diagnostics steer to the slice-of-protocol variadic instead.
Repurposed examples/162-pack-bare-args.sx (was an aspirational bare-$args->[]Any
auto-materialise, contradicting Decision 1) into the slice-form forward
(..args: []Any). examples/203 is the four-category negative test. specs.md "Pack
as value" updated. 238 examples + unit green.
packVariadicCallArgs stored the raw concrete arg into a [N x P] array when the
element type was a protocol, so an 8-byte struct landed in a 16-byte {ctx,
vtable} slot -> garbage vtable -> Bus error on dispatch. Now, when the slice
element type is a protocol, each arg is xx-erased to the protocol value via
buildProtocolErasure (same impl-driven machinery as the xx cast). This makes
..xs: []P the runtime, protocol-erased counterpart to the comptime
heterogeneous pack ..xs: P (which stays comptime-only): xs[runtime_i].method()
now works in an ordinary loop.
specs.md: full variadic/pack form-comparison table (concrete-vs-erased,
comptime-vs-runtime). Regression: examples/202. Issue 0052 (FIXED). 237 green.
Per locked Decision 1 a pack is comptime-only with no runtime value, so xs[i]
is valid only for a comptime index. lowerIndexExpr now emits a clear error
("pack <p> must be indexed by a compile-time constant ...") for a runtime
index, instead of the confusing "unresolved <p>" the slice-index fall-through
produced. diagPackIndexOOB switched from int-literal-only to comptimeIndexOf so
an inline-for cursor that goes out of bounds is also caught.
Repurposed examples/163-pack-runtime-index.sx (was aspirational: expected
runtime indexing to materialise a []Any slice and print 4, contradicting
Decision 1) into the runtime-index error test. Comptime + OOB cases already
covered by examples/199/200/161. 236 examples + unit green.
The three post-diagnostic failure returns in resolveTypeArg (pack-index OOB,
no active pack binding, unresolved type name) returned .void as a sentinel.
Per the CLAUDE.md rule (.void is unacceptable for a failed type lookup -- it
conflates with the real void type), use the dedicated .unresolved sentinel.
They follow addFmt(.err) so compilation aborts before codegen; behavior is
unchanged, the sentinel is now correct. 236 + unit green.
Var-init placeholders that could leak when a lookup failed now init to
.unresolved: struct field-not-found (lowerFieldAccess/store), match payload
variant-not-found, deref-of-non-pointer pointee, array-literal element type.
Also fixes checks that used .s64 as the "resolution failed" sentinel and broke
when the producing functions started returning .unresolved instead:
- array-literal: `resolved != .s64` -> `!= .unresolved`.
- parameterized type-alias registration and pack-fn return-type resolution:
`!= .s64` -> `!= .unresolved` (also fixes a latent bug where a genuine
`s64` alias / `-> s64` return was treated as a failure).
- the variadic Any-boxing refinement (infer, then upgrade via getRefType) now
triggers on .unresolved, not .s64, matching the honest inferExprType.
Every silent s64 fallback in the codebase is now gone; only genuine s64<->name
mappings and the defined int-literal/tag-width defaults remain. 236 + unit green.
Converts the leftover silent s64 guesses in lowering/type-resolution paths:
- target_type orelse .s64 in struct/tagged-union/enum-literal lowering and the
xx-cast destination (the isBuiltin-guarded ones skip cleanly; the rest now
surface instead of fabricating an int).
- resolveTypeArg / parameterized-type callee-name else arms.
- generic-mangle type-param binding miss (bindings.get orelse .s64).
- optional-child helper fallthrough.
Kept the genuine int/float-literal defaults (info.ty orelse .s64/.f64) which
are the language rule, not a lookup failure. 236 examples + unit green.
inferExprType now returns .unresolved when it genuinely cannot infer a type,
instead of silently guessing .s64. To keep codegen correct, every consumer
that turns inference into a concrete type was fixed to resolve it properly
rather than lean on the fake s64:
- pack-fn mono: value-pack params type from the lowered Ref (getRefType);
comptime ..$args prefers inference (int-literal default is s64) and falls
back to the lowered type only when inference cannot tell.
- if-expr / match merge result type: fall back to the contextual target_type
when the branch/arm type is not statically inferable; a statement match with
non-value arms stays void (do not let a leaked target_type make it a value).
- inferExprType call arm: resolve a not-yet-lowered function return type from
fn_ast_map (void for a return-less fn) instead of falling through.
- lowerBinaryOp: type the result from the lowered LHS when inference is
unresolved (e.g. #objc_call(...) * 2).
- null comparison (x == null): lower the non-null side first and take the
null type from it, never a guess.
A consequence: `xx enum` with no target type now boxes as Any (prints the
variant name) instead of the silent-s64 int -- examples/52 snapshot updated to
the honest output. 236 examples + unit tests green.
lowerPackFnCall computed pack arg types via inferExprType *before* lowering
the args, then lowered them anyway. For a value-pack (..xs: P) the lowered
value has an authoritative concrete type, so take the pack type from
getRefType of the lowered Ref instead of a speculative inferExprType guess --
this removes the dependency that made a monomorphised pack param able to end
up wrong/.unresolved from incomplete static inference. Comptime ..$args packs
keep inferExprType (their args may be type-position). Also drops the dead
runtime_arg_types list (collected, never read). 236/236 green.
resolveFieldType (field-not-found, tuple OOB/parse-fail), getElementType
(element-of-a-non-collection), resolveArrayLiteralType, and the named-type
lookup in the type-call resolver all guessed .s64 when resolution failed --
the issue-0042 silent-default class. Return .unresolved so a genuine
resolution failure surfaces (and trips the sizeOf/toLLVMType panic) instead
of fabricating an 8-byte int. Genuine results (.len => .s64) unchanged.
The OOB-index and missing-binding cases already emit a real user-facing
diagnostic, but returned a plausible .s64 -- which would silently fabricate
an 8-byte int if compilation continued past the error. Return the
.unresolved sentinel instead (trips the sizeOf/toLLVMType panic at codegen).
Diagnostic text unchanged, so snapshots are unaffected.
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.
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.