The error slot of a value-carrying failable can no longer be silently dropped
on a bare destructure. In lowerDestructureDecl, when the RHS is failable
(errorChannelOf(ty) != null), the error slot (always the last tuple field)
must be bound to a non-`_` name. Reject when it is omitted entirely (fewer
names than slots — e.g. `a, c := inc(5)` for `inc: -> (s32,s32,!E)`) or bound
to `_` (`v, _ := parse(5)`).
The `try` / `catch` / `or value` consumer forms all strip the error channel
(their result type is non-failable), so the check never fires on them — only a
bare failable destructure is rejected. Value-slot `_` discards stay legal
(`a, _, ae := pair()` binds the error).
This is the discard-rejection slice of E1.8; the path-sensitive flow-check
(value live only where err==null is provable) is a separate follow-up.
examples/236-failable-discard-reject.sx covers both rejected shapes (exit 1).
Gates: zig build, zig build test, 274/274 examples.
Generalize the single-value `-> (T, !)` error-channel ABI to any value
arity. Retire the five `fields.len == 2` bails (lowerFailableSuccessReturn,
lowerTry, lowerCatch, lowerFailableOr, and the inferExprType try/catch/or
arms); lowerRaise + emitErrorReturn already looped over N value slots.
New helpers centralize "value-part = every slot but the last (error) one":
failableSuccessType (lone value type, or a value-tuple), extractSuccessValue,
extractErrorSlot.
Fix one latent bug the feature surfaced: coerceToType had no tuple->tuple
arm, so a value-tuple flowing into a differently-typed success slot (e.g.
(s64,s64) catch body into (s32,s32)) fell through unchanged. Add element-wise
coercion. No lowerTupleLiteral change is needed: a `return (a, b)` literal
against a 3-field failable target already gets target_fields=null via the
arity mismatch, so it types as a plain value-tuple that
lowerFailableSuccessReturn consumes.
examples/235-multi-value-failable.sx exercises producer return/raise,
destructure (binding every slot incl. the error tag), multi-value try
(success + propagation), catch (bare-expr tuple body), and or-tuple
terminator. Match-body tuple arms are left out: `(` after `case PAT:` is
parsed as a payload capture (a pre-existing, multi-value-unrelated parser
bug). Gates: zig build, zig build test, 273/273 examples.
`onfail [e] BODY` runs cleanup only when an error LEAVES the enclosing block
(a `raise` or a propagating `try`), and is skipped on success — unlike `defer`,
which runs on every exit. On an error exit, defers and onfails run interleaved
in reverse declaration order; `onfail e` binds the in-flight error tag.
- Cleanup stack: defer_stack now holds CleanupEntry { body, is_onfail, binding }
(one declaration-ordered stack so defer/onfail interleave). lowerDefer pushes
a defer entry; lowerOnFail (new `.onfail_stmt` arm) pushes an onfail entry,
rejecting `onfail` outside a failable function.
- emitBlockDefers (success exits — return / normal block exit) now emits only
`defer` entries and discards onfails.
- emitErrorCleanup (new; wired at the error exits — lowerRaise pure +
value-carrying, lowerTry propagation) emits both kinds interleaved in reverse,
binding the in-flight tag for `onfail e`.
Block-rooted: an error propagating to the function drains all enclosing blocks'
onfails; a block that exits normally discards its onfails. Per-attempt-`try`
gating is moot for now (no compilable `or` chain can absorb a mid-block try
failure yet — E2.4b). Body restrictions beyond the parser's raise-in-onfail
ban are deferred.
Tests: examples/233-onfail.sx (interleave order on error vs success + binding;
deterministic trace), examples/234-onfail-reject.sx (onfail outside a failable
fn rejected; exit 1). Gates: zig build, zig build test, 272/272 examples.
`lhs or value` where `lhs` is a value-carrying failable (`-> (T, !E)`): on
success the result is the LHS value, on failure the LHS error is discarded and
the result is the terminator value — the whole expression is non-failable (T).
Unblocked by the value ABI (E2.1); needs no fallback-routing (it's a 2-operand,
non-chained `or`).
- lowerBinaryOp `.or_op`: a failable LHS now routes to lowerFailableOr instead
of the E1.4a loud bail; non-failable `or` (boolean / optional-unwrap)
unchanged.
- lowerFailableOr: chain form (a `try`-marked LHS, whose own type is its
success value, or a failable RHS) bails → E2.4b (fallback routing). Pure
failable `or value` rejected ("no success value to fall back to — use
catch"). Value-carrying: tuple_get the value/error, condBr, merge the LHS
value (success) or the terminator (failure) through a block-param phi.
Multi-value bails (E2).
- inferExprType `.or_op`: a failable `or value` types as the LHS success type
(was always `.bool`); non-failable `or` still `.bool`.
Tests: examples/231-failable-or.sx (success + Bad + Empty terminators; exit
116), examples/232-failable-or-reject.sx (pure-failable `or value` rejected;
exit 1). Gates: zig build, zig build test, 270/270 examples.
The consumer side of the error-channel tuple ABI. A value-carrying `-> (T, !E)`
failable can now be consumed by `try` and `catch` (not just destructured).
Single-value; multi-value `-> (T1, T2, !)` consumers bail (E2).
- lowerTry: a value-carrying callee returns `{v, err}`. Extract `err`
(tuple_get field 1), branch; on success the try value is `tuple_get(field 0)`,
on error propagate via emitErrorReturn (pure caller → `ret(tag)`;
value-carrying caller → `ret {undef..., tag}`). Widening now runs for
value-carrying callees too. Retires the two value-carrying bails.
- lowerCatch: a value-carrying LHS merges through a block-param phi — the
success edge feeds `tuple_get(field 0)`, the handler edge feeds the body's
value (coerced to the success type). runCatchBody factors the bound-tag body
lowering (force_block_value for the value case). Pure-failable catch
unchanged.
- A non-diverging value-carrying catch body that yields no value is now a
clean diagnostic ("`catch` body must produce a value … or diverge") instead
of coercing `void` into a bad ref / failing LLVM verification — caught by an
adversarial review of the lowering.
Tests: examples/229-value-failable-consume.sx (try in value-carrying + pure
callers, catch block/bare/match-body/diverging bodies; exit 32),
examples/230-value-failable-reject.sx (void catch body rejected; exit 1).
Gates: zig build, zig build test, 268/268 examples.
The producer side of the error-channel tuple ABI for value-carrying `-> (T, !)`
functions. A failable that returns a value OR an error now lowers correctly;
the result is consumed via destructure (`v, err := f()`). Single-value
`-> (T, !)`; multi-value `-> (T1, T2, !)` and the value-carrying try/catch
consumers (E2.1b) follow.
- lowerReturn: a value-carrying failable's `return v;` assembles the success
tuple `{v, 0}` (compiler appends the no-error slot) via lowerFailableSuccessReturn
(tuple_init). Forwarding a full failable tuple (`return other_failable()` /
explicit `return (v, e)`) returns as-is. Multi-value returns bail loudly (E2).
- lowerRaise: the value-carrying branch (previously a loud bail) now builds
`{undef value slots..., tag}` (constUndef per value slot + the error tag) and
returns it — any arity.
- helpers: buildFailableTuple (tuple_init from value refs + tag) + emitTupleRet
(return honoring inline-comptime targets).
Value-carrying `try` / `catch` still bail (E2.1b). Tests:
examples/228-value-failable.sx (return value + both raises, consumed by
destructure; exit 60). Gates: zig build, zig build test, 266/266 examples.
`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.
Adds the `.error_type_expr` arm to type_bridge.resolveAstType (the gating site
that still returned `.unresolved`):
- `!Named` → resolveTypeName(name) → the declared error set (E1.1).
- bare `!` → a shared inferred placeholder error set (reserved name "!", empty
tags), refined per failable function by the E1.4 SCC pass.
The error channel then falls out of the existing multi-return + tuple
machinery: `-> (s32, !Named)` is a tuple_type_expr whose last field is the
error_type_expr → resolves to a tuple {s32, error_set} — exactly the locked
ABI (error slot = last return slot, u32). `-> !Named` resolves to the set.
Verified end-to-end via scratch: `parse :: (n) -> (s32, !ParseErr) { ...;
return (n, e); }` compiles + runs, `v, err := parse(5)` destructures (err typed
as the error set), `err == error.X` works; `-> !Named` single return too.
3 unit tests in type_bridge.test.zig (!Named, bare ! placeholder, tuple ending
in the error set). No examples/ — the only current usage path (return
(value, error)) will be flow-check-rejected at E1.8; the blessed example waits
for E1.3 (raise) + try/catch consumption.
zig build, zig build test (275), and 256/256 examples green.
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.
- types.Type: add dedicated `unresolved` variant (mirrors ir.TypeId.unresolved)
with eql/displayName arms; bridgeType maps it to TypeId.unresolved.
- sema.inferExprType + signature/field resolution: every Type.fromTypeExpr /
fromName / symbol lookup miss and call/field/index fallthrough now yields
Type.unresolved instead of a fabricated s(64). A variadic `..xs: []T` slice
element is taken from T, not a guessed "s32". Genuine literal defaults
(int=>s64, float=>f32, .len=>s64) kept.
- Builder.getRefType: an unlocatable ref (no active function / out-of-range)
returns .unresolved, not .s64 -- this is the accurate type source the pack
mono / binop / null-cmp fixes rely on, so it must not fabricate.
236 examples + unit tests (incl sema) green.
A signed/unsigned width other than 8/16/32/64 quantised to s64/u64, silently
changing the size. Intern the exact .signed/.unsigned width instead (the IR
supports arbitrary-width ints). The default tagged-union tag width
(tag_type orelse .s64) is kept -- it is a defined language default, not a
failed lookup. 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.
The pack-aware caller (resolveTypeWithBindings) resolves pack-index type
exprs against the active binding before delegating, so reaching this bare
type_bridge path means the binding was missing. .s64 silently fabricated
an 8-byte int; return the .unresolved sentinel so it surfaces (trips the
sizeOf/toLLVMType panic at codegen). Closes the last .s64 escape in
resolveAstType.
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.