A namespaced import's const (`m :: #import "lib.sx"; … m.CAP`) only ever
resolved as a runtime value — the const folders in program_index.zig had no
namespace-member arm, so a qualified const was rejected as an array dimension /
Vector lane / generic value-param and could not seed another const, while the
flat-import form worked everywhere.
Add a `lookupQualifiedConst` (+ float / float-typed twins) ctx hook: resolve
the alias via `namespaceAliasVerdictFrom` to its target module, then fold the
member from that module's per-source const cache (`foldQualifiedConstInt` in
lower/comptime.zig), pinned to the target source so nested const RHSs fold
there. Wire it into evalConstIntExpr / evalConstFloatExpr / isFloatValuedExpr —
both the expression-position field_access arm (`[m.CAP]T`) and the
type-argument dotted-name arm (`Vector(m.LANES, …)`, generic value-params).
Implemented on the source-aware ctxs (Lowering / SourceConstCtx); the
namespace-blind ModuleConstCtx / StatelessInner return null, so a qualified-const
dim reached only via the stateless type-alias path stays a clean unresolved-dim
diagnostic, never a fabricated length. Resolves correctly for array dims,
arithmetic, integral-float dims, Vector lanes, generic value-params, inline-for
bounds, and struct fields.
Regression: examples/modules/0842-modules-qualified-import-const-comptime.sx.
A failable function that returned by IMPLICIT success (no explicit
`return`) left its error-tag slot uninitialized, so a caller's `catch` /
`or` (or `main`) read a garbage tag and reported a phantom unhandled
error — and for value-carrying failables the success value was dropped.
The "no error" sentinel was only written on the explicit-`return;` path.
Unified all function-body-return lowering so the failable-success slot
is always written:
- void `-> !` fall-through: `ensureTerminator` (control_flow.zig) now
emits `ret constInt(0)` for a pure-failable end-of-body.
- value-failable trailing-expression success: `lowerValueBody`
(stmt.zig) routes through `lowerFailableSuccessReturn`.
- generic + pack-fn instances: `monomorphizeFunction` (generic.zig) and
`monomorphizePackFn` (pack.zig) now DELEGATE their body-return to
`lowerValueBody` instead of hand-rolling a `coerce`+`ret` that drifted
(covers generic/pack value-failables).
Also fixes the missing-value diagnostic guard added here: it now counts
`.err`-level diagnostics (new `DiagnosticList.errorCount`) rather than the
total list length, so a warning/note emitted while lowering the body
(e.g. an ObjC selector arity warning) can no longer suppress a genuine
"body produces no value" error — which previously shipped an
uninitialized return at exit 0.
Regressions: examples/errors/1061 (void fall-through), 1062 (value-failable
trailing expr), 1063 (generic value-failable trailing expr).
Two type-resolution paths silently resolved a non-type AST node in type
position to a zero-field `{}` struct that reached codegen with no
diagnostic:
- a dotted `type_expr` / field-access (`g.a`, `g` a runtime value) whose
prefix is not a namespace alias
- an `error_type_expr` (`!Name`) whose `Name` is not a declared error set
Now both reject loudly:
- `resolveTypeWithBindings` (lower.zig): "expected a type, found a value
'<name>' in type position" + `.unresolved`
- `checkTypeNodeForUnknown` (semantic_diagnostics.zig): validates a named
`!E` against the declared error-set names — "unknown error set
'<name>'" / "expected an error set after '!', found type '<name>'".
A bare `!` (void channel) and a declared `!E` in return position stay
valid; namespace-qualified types (`pkg.Type`) are unaffected.
Regression: examples/diagnostics/1195-diagnostics-non-type-in-type-position.
Replace the bare-paren tuple grammar with explicit, position-unambiguous
forms, mirroring how structs work:
type `(A, B)` -> `Tuple(A, B)` (named keeps `:`)
value `(a, b)` -> `.(a, b)` (named uses `=`)
typed (new) -> `Tuple(A, B).(a, b)` (like `Point.{...}`)
failable `-> (T, !)` -> `-> T !`
`-> (T1, T2, !)`-> `-> Tuple(T1, T2) !` (channel outside Tuple)
Bare `(...)` is now grouping only, everywhere; a comma in bare parens is a
hard error with a migration hint. Grouping, function types `(A, B) -> R`,
param lists, lambdas, and match bindings are unaffected.
`Tuple(...)` is strictly a TYPE in every position (including `size_of` /
`type_info` args); a tuple VALUE comes only from `.(...)` (anonymous) or
`Tuple(...).(...)` (explicitly typed). A bare `Tuple(1, 2)` is a tuple
type with non-type elements -> rejected.
The ~110 tuple-bearing corpus files were migrated with a one-shot
AST-aware migrator (the `sx migrate` tool from the prior commit, removed
here). New examples: 0130 (new syntax), 0131 (typed construction), 1060
(named-tuple failable return). 1116 golden updated for the new hint text.
Temporary scaffolding for the tuple-syntax cutover. Parses old-grammar
.sx and rewrites tuple syntax to the new spelling:
- tuple TYPES `(A, B)` -> `Tuple(A, B)` (named keeps `:`)
- tuple VALUES `(a, b)` -> `.(a, b)` (named flips `:` -> `=`)
- 1-tuples / empty / spread -> `.(x)` / `.()` / `.(..xs)`, `Tuple(..Ts)`
- failable returns: the `!` channel stays OUTSIDE Tuple
`-> (T, !)` -> `-> T !`
`-> (T1, T2, !)` -> `-> Tuple(T1, T2) !`
AST-walk based: rewrites only `tuple_literal` / `tuple_type_expr` nodes
(function types, param lists, match bindings, arrays, struct literals,
Closure sigs, groupings are left untouched). Nested tuples rewrite
recursively as a single non-overlapping edit per outermost tuple.
Value-vs-type ambiguity (call-arg tuples whose elements could be types,
e.g. `size_of((Box, i32))`, empty `()`) is never guessed: such sites go
to a worklist. A non-empty worklist exits nonzero and suppresses the
"looks-done" stdout output unless `--force` is passed.
`sx migrate <f>` prints migrated source; `--dry-run` prints only the
worklist. Built against the old grammar; removed after the cutover.
Optional (?T) operands were implicitly unwrapped without proof of
presence, silently miscompiling a NULL ?T to garbage. Unwraps in
binary ops and other expression positions are now gated on flow
narrowing: a ?T value is only auto-unwrapped where control flow has
established it is non-null (the narrowed_refs set). Outside a narrowed
region, an implicit unwrap is rejected rather than producing garbage.
Touches the lowering pipeline (lower.zig + lower/{call,closure,coerce,
comptime,control_flow,expr,ffi,generic,pack,stmt}.zig). Adds optionals
examples 0919-0923 and closures example 0312 covering flow narrowing,
binop narrowing, no-implicit-unwrap rejection, and no closure leak of
narrowed state. Updates specs.md and readme.md.
The body-local #run fold in emitCall was effectively dead (gated on
args.len==0, but the __ct comptime wrapper always carries the implicit
*Context arg), so every body-local #run fell through to a RUNTIME call:
bridgeable shapes lucked into the right value; an unbridgeable shape
(e.g. [2][]i64) ran over --- storage -> garbage, exit 0, no diagnostic.
Fold any is_comptime callee (gated !enclosing.is_comptime so nested
metatype calls in a comptime wrapper's dead body aren't folded). On a
tryEval bail, distinguish a BRIDGE bail (result can't regToValue-
materialize -> error: comptime init of 'X' failed: <reason> +
comptime_failed, build fails, symmetric with the global #run path) from
an EXECUTION bail (VM can't run the body, e.g. NaN/extern -> runtime
fallthrough, preserving types/0150), via comptime_vm.last_bail_was_bridge
(reset at tryEval entry, set only at regToValue). The const name is
threaded onto the wrapper (comptime_display_name) so the diagnostic reads
the source name, not __ct_N.
Regressions: diagnostics/1204 (negative), comptime/0645 (positive).
Verified by 3 adversarial reviews, suite 801/0.
lowerIndexExpr fell through to an index_get with an .unresolved element
type for any non-indexable object (*T, *[]T, struct, scalar), reaching
codegen -> 'unresolved type reached LLVM emission' panic. Add a guard
after all indexable arms: if getElementType(obj_ty) is .unresolved and
obj_ty is itself resolved (genuinely non-indexable, not a prior-error
placeholder), emit a located 'cannot index a value of type <T>'
diagnostic + placeholder (hasErrors aborts before codegen). A single
pointer hints by pointee: ptr-to-scalar -> many-pointer/dereference;
ptr-to-array/slice -> dereference first. No false-positives (generics,
aliases, late-resolved, every indexable shape verified).
Regression: examples/diagnostics/1203-diagnostics-index-non-indexable.sx.
Verified by 3 adversarial reviews, suite 799/0. Filed adjacent pre-existing
panic 0184 (untyped positional .{ } literal with no target type).
The generic-?? wrong-fallback was not in lowerNullCoalesce: coercing
?A -> ?B (differing payload, e.g. the ?i32->?i64 call-arg coercion when
instantiating unwrap_or(99, ?i32)) routed through .optional_wrap, which
unconditionally unwrapped the source and re-wrapped as ALWAYS-PRESENT, so
a null became present-zero everywhere (args, returns, field init,
var-decl, ??). Add a CoercionPlan.optional_to_optional (conversions.zig)
+ a presence-preserving arm in coerceMode (coerce.zig): has_value ->
present: unwrap+coerce-child+wrap-present; absent: constNull(dst); merge
via a dst_ty block param. lowerVarDecl gains a !src_is_optional guard so
an annotated x : ?B = <?A> routes through the same arm (also makes
aggregate-payload var-decl ?[3]i64->?[]i64 / ?Concrete->?Protocol work).
Alias-optional struct-literal default already works (grouping + 0166);
a 1-tuple default ?(i32,) ?? 5 now emits a clean diagnostic instead of an
LLVM PHI abort (no implicit scalar->1-tuple coercion per spec).
Regressions: optionals/0916 (generic ??), 0917 (alias struct default),
0918 (var-decl optional->optional), diagnostics/1202 (1-tuple default) +
a conversions.test.zig unit test. Verified by 3 adversarial reviews,
suite 798/0.
opt?.xs[i] typed and lowered the index over the optional CONTAINER
(?[N]T); getElementType returned .unresolved, so index_get reached LLVM
with an unresolved element type and panicked. Mirroring the 0101
!-unwrap fix: add lowerOptionalChainIndex (optional_has_value -> some:
unwrap + index (index_gep+load for ?*[N]T, else index_get) +
optional_wrap; none: const_null; merge -> ?ElemType, element-optional
flattened). The typer + dispatch guard compute the element via
ptrToArrayElem(child) orelse getElementType(child), so value-arrays,
slices, many-pointers, AND pointer-to-array (?*[N]T) children resolve.
Null receivers short-circuit (no null deref).
Regression: examples/optionals/0915-optional-chain-array-field-index.sx.
Verified by 3 adversarial reviews, suite 794/0. Filed broader pre-existing
gap 0183 (indexing a non-indexable type panics instead of diagnosing).
(C) regToValue (comptime_vm.zig) gained no array arm, so a #run returning
an aggregate containing an array bailed 'reg->value: aggregate shape not
bridged yet'. Add an .array arm: read N elements at stride
typeSizeBytes(elem) from the array address, bridge each recursively via
regToValue -> an .aggregate Value (serializeAggregateValue already emits
arrays). Composes with struct fields, nested arrays, array-of-structs,
and the ?Arr optional payload; unbridgeable elements bail loudly.
(E) A global failing #run proceeded into LLVM emission and panicked
'unresolved type reached LLVM emission' when the unresolved const was
used. Add 'if (self.comptime_failed) return;' in emit() after Pass 0 so
it aborts cleanly (exit 1, the comptime diagnostic) across run/ir/build.
Regression: examples/comptime/0644-comptime-run-array-aggregate.sx.
Verified by 3 adversarial reviews, suite 793/0. Filed separate bugs found
during review: 0181 (optional-chain ?. to array field + index panics),
0182 (body-local #run unbridged silently miscompiles).
In type position, parentheses now mirror value position: (T) (a single
unnamed element, no trailing comma) is a GROUPING that resolves to the
inner type; (T,) is a 1-tuple; (A, B) a 2-tuple; named (x: T) and spread
(..Ts) stay tuples; (...) -> R stays a function type. This lets a
closure/optional/function type be parenthesized for readability without
silently becoming a 1-tuple:
[1](Closure(i64,i64) -> i64) // array of closures (issue 0177) -> 7
?(?i64) // genuine nested optional (issue 0165 intent)
Parser: src/parser.zig returns the inner node for a single unnamed
non-spread no-trailing-comma parenthesized type. formatTypeName (both
generic.zig diagnostics + types.zig reflection) now render a 1-tuple as
(T,) so the spelling is unambiguous and diagnostics are self-consistent.
The 0165 coerce/stmt note reworded accordingly.
specs.md §Type Syntax updated; basic/0036 wrap return -> (i64,); obsolete
diagnostic 1195 removed (?(?i64) now compiles); regression
examples/types/0201-types-parenthesized-type-grouping.sx added; 0414 .ir
golden regenerated for the (T,) rendering. Resolves 0177; updates
0165/0170. Verified by 3 adversarial reviews; suite 792/0.
The issue-0176 conformance gate was name-only, so an impl P for T with a
mismatched return/param type (or arity) built a wrong-ABI thunk that
silently miscompiled (exit 0, wrong value). firstUnimplementedMethod now
validates arity (after self), each param type, and the return type
against the protocol declaration, substituting protocol Self->concrete
via resolveProtoTypeSubSelf (recurses through pointer/many-pointer/
optional/slice/array so []Self<->[]T match; conservative .unresolved for
Self-in-generic-arg). Comparison is by structural formatTypeName
(alias/module/spelling independent); typesClearlyDiffer skips when either
side has an unresolved leaf at any depth, biasing against false-positives.
Regressions: diagnostics/1201 (negative), protocols/0420 (positive,
[]Self param). Verified by 3+3 adversarial reviews (a mid-fix []Self
false-positive was found and closed); suite 792/0.
lowerNullCoalesce fed resolveOptionalInner's .unresolved (returned for a
non-optional lhs) into the merge-block params / optionalUnwrap / RHS
target type, reaching codegen and panicking 'unresolved type reached
LLVM emission'. Guard: when inferExprType(nc.lhs) is a resolved
non-optional type, emit a located diagnostic and bail; an .unresolved
lhs (prior error) is excluded to avoid double-report. ?? is optional-only
per specs.md (error unions use or/catch), so rejecting a failable lhs is
correct; comptime panic closed too.
Regression: examples/diagnostics/1200-diagnostics-null-coalesce-non-optional.sx.
Verified by 3 adversarial reviews, suite 790/0. Filed adjacent bug 0180
(?? lowering defects for generic/alias/tuple optional lhs).
The Optional->Concrete unwrap classify rule treated ?i64 -> bool as
unwrap+narrow (both builtin), silently yielding false for every optional
(present or null). specs.md defines no implicit optional->bool
conversion. Reject it: conversions.zig adds an optional_to_bool_reject
plan (dst == bool, child != bool); coerce.zig emits a located diagnostic
suggesting '!= null'. Covers arg/field-init/return via the shared
coerceMode. The if-opt presence test (issue 0164) is a separate path,
untouched.
Regression: examples/diagnostics/1199-diagnostics-optional-to-bool.sx +
conversions.test.zig unit test. Verified by 3 adversarial reviews, suite
789/0. Filed adjacent issue 0179 (whole implicit ?T->concrete unwrap
family silently miscompiles a null optional; design-touching).
Erasing a type to a protocol when it conforms only via a free function
(not an explicit impl P for T) built a vtable of unreachable thunks ->
SIGABRT on first dispatch, with no diagnostic. Per specs.md erasure is
impl-driven, not structural, so the erasure was never valid.
Add a conformance gate (firstUnimplementedMethod in buildProtocolValue,
src/ir/lower/protocol.zig): emit a located diagnostic when a protocol
method has no reachable impl, or when an impl method introduces its own
type params (signature mismatch — it bails lazyLowerFunction and would
reach the unreachable thunk). A std.debug.panic tripwire guards the
diagnostics==null path so a non-conforming erasure can never silently
ship as undef. Gate<->thunk equivalence verified bidirectional.
Regressions: protocols/0419 (positive struct-field dispatch),
diagnostics/1197 (no-impl) + 1198 (generic-method signature mismatch).
Updated memory/0808 (it erased a non-conforming type that never
dispatched). Verified by 3+1 adversarial reviews, suite 788/0. Filed
adjacent bug 0178 (protocol impl method type-mismatch silent miscompile).
Calling through an unwrapped optional closure (g!()) crashed with LLVM
'Called function must be a pointer!': the indirect-call catch-all else
arm emitted call_indirect on the whole {fn,env} closure struct with a
hardcoded .i64 return. The else arm now inspects inferExprType(callee):
a .closure callee dispatches through call_closure (threads env + ctx via
the [ctx, env, user_args] ABI, returns closure.ret); a plain fn pointer
uses call_indirect with the callee's real function.ret instead of i64.
The filed repro's ?(() -> void) spelling is a tuple-optional (now
diagnosed by the 0165 fix); the real ?Closure(...) layout was already
correct. Verified load-bearing (HEAD crashes) by 3 adversarial reviews,
suite 785/0. Regression: examples/closures/0311-closures-optional-closure.sx.
Filed adjacent bug 0177 (array-element closure direct call crashes).
0173: resolveArrayLiteralType gained no arm for [N]T/[]T heads, so a
([2]?i64).[...] head lost its ?i64 element type and a bare null reached
LLVM as const_null(.unresolved). Route structural heads through
resolveTypeWithBindings; validate an undefined element name in the head
via UnknownTypeChecker (semantic_diagnostics.zig) instead of a silent
empty-struct stub (no-silent-fallback).
0174: positional .{...} against a TUPLE target now coerces each element
to TupleInfo.fields[i] (was neither struct nor array, so uncoerced).
0175: a positional struct literal with a bare-variable element was
misclassified as a named shorthand (parser puns .{x} -> x=x), zeroing
the fields. has_names now consults the struct definition to reclassify a
punned non-field name as positional; positional coercion uses the
lowered value's real getRefType.
Regressions: optionals/0914, types/0199, types/0200, diagnostics/1196.
Verified by 4 adversarial reviews; suite 784/0. Filed adjacent bug 0176
(protocol-typed struct field method call aborts).
[N]?T arrays were corrupted: a positional literal .{ null, 7 } stored
bare T/null elements into {T,i1} optional slots because array elements
were never coerced (getStructFields is empty for an array, so the
i<struct_fields.len field-coercion gate never fired). A present element
then read back as absent and direct indexing segfaulted.
lowerStructLiteral's positional branch now computes array_elem_ty for
array/vector targets and coerces each element to it; lowerArrayLiteral
generalizes its slice-only coercion to coerce every element via
coerceToType (layout-aware: scalar->{T,i1}, pointer-sentinel->one-word,
array->slice, concrete->protocol). Verified by 3 adversarial reviews,
suite 780/0.
Regression: examples/optionals/0913-optionals-array-of-optionals.sx.
Filed adjacent pre-existing bugs: 0173 (typed .[null,..] element), 0174
(tuple positional-element coercion), 0175 (positional struct literal
variable element zeroed).
The RHS of a null-coalesce was lowered with no target type, so a bare
struct literal default (x ?? .{ ... }) produced a struct_init with
.ty == .unresolved that panicked in emitStructInit. lowerNullCoalesce
now saves self.target_type, sets it to the optional's resolved child
before lowering nc.rhs, and restores it (leak-free). Verified across
struct/slice/enum/tuple/protocol/nested-optional/generic child types by
3 adversarial reviews.
Regression: examples/optionals/0912-null-coalesce-struct-literal.sx.
Filed adjacent pre-existing bug 0172 (?? on a non-optional lhs panics).
In type position (T) is a 1-tuple (specs.md:843), so ?(?i64) is
optional(tuple(?i64)); assigning a bare ?i64 had coerceToType classify
.none and pass the value through, then optionalWrap built a corrupt
insertvalue that aborted the LLVM verifier. After coercing toward an
optional's child, verify the coerced type equals the child type
(stmt.zig decl-init + coerce.zig .optional_wrap); on mismatch emit a
located diagnostic (tuple-specific note only when the child is a tuple).
formatTypeName now renders tuples as (x: i64, y: i64).
Regressions: optionals/0911 (nested optional via alias, round-trip),
diagnostics/1195 (the mismatch diagnostic). Updated diagnostics/1101 +
protocols/0414 goldens for the improved tuple type-name rendering.
Verified by 3 adversarial reviews. Filed adjacent bug 0171 (?any child
not canonicalized).
lowerIfExpr emitted optional_has_value only for the binding form; a bare
'if opt' passed the raw {T,i1} aggregate to condBr, where emitCondBr's
catch-all struct arm silently folded it to 'i1 true' (structs always
truthy) — a silent miscompile that took the present-branch for null
optionals. while / and / or shared the same defect.
Reduce bindingless optional conditions to optional_has_value in
lowerIfExpr/lowerWhile and via a new lowerBoolCondition helper for and/or
operands. Replace the silent-true emitCondBr arm with a lowering-time
diagnostic (checkConditionType/isValidConditionType) rejecting conditions
whose type isn't bool/integer/pointer/optional; the backend @panic is now
an unreachable tripwire.
Regressions: examples/optionals/0908..0910 + diagnostics/1194 (negative).
Verified by 3+3 adversarial reviews.
Filed adjacent bugs found during review: 0168 (array-of-optionals element
load), 0169 (optional->bool coercion), 0170 (closure-optional layout).
Add an .optional arm to regToValue in comptime_vm.zig: read the
has_value flag at offset sizeof(child), bridge the payload recursively
into a { payload, i1=true } aggregate when set, yield .null_val (zero
{T,i1}) when clear or the bare null sentinel. Matching serialize arm in
serializeAggregateValue (emit_llvm.zig). Pointer/?Closure/?Protocol-child
optionals and array-payload aggregates bail loudly, not silently.
Regression: examples/comptime/0643-comptime-run-optional-aggregate.sx
(present ?T, present ?i64, null ?i64). Verified by 3 adversarial reviews.
Five adversarial reviews of the issue-0160 fix surfaced three more bugs in the
touched optional-chain / optional-coercion code; all fixed here:
1. A COLD generic-instance getter through `?.` (`?*Vec(i64)` `.getter`, never
called directly first) panicked with "unresolved type reached LLVM emission":
a cold instance method is absent from resolveFuncByName, so the getter's
return type resolved to .unresolved → a ?unresolved merge type. lowerOptionalChain
and getterReturnTypeOnDeref now warm the monomorph (ensureGenericInstanceMethodLowered)
before querying its return type. (The 0907 test passed only by luck — List(i64)
is warmed by stdlib use; 0907 now also exercises a cold user generic.)
2. A real-field read through a `?*T` chain (`op?.field`, op: ?*T) reinterpreted
the pointer bits as the field (silent garbage) — the some-branch real-field
path didn't load through the pointer. It now derefs `?*T` before the field
access. (Pre-existing — the else-branch predates 0160 — but it's the same
function and a silent miscompile, so fixed here.)
3. `?[]T = array` skipped the array→slice promotion (corrupt .len/.ptr): the
lowerVarDecl optional arm wrapped the raw array. It now coerces the value to
the optional's child type (array→slice) before wrapping.
Regression examples 0906/0907 extended to cover all three. Distinct PRE-EXISTING
bugs the reviews surfaced in untouched subsystems are filed as issues 0161
(struct-literal vs scalar), 0162 (#run returning an optional aggregate), 0163
(untagged-union payload-binding match).
Two fixes for optional interactions surfaced by the #set/#get review. The
original issue 0160 mis-diagnosed (A) as an optional-chain bug; the chain works
fine for real fields. The actual bugs:
(A) A bare struct literal `.{ ... }` against an optional target `?T` was built
into the optional's {payload, has_value} layout instead of the inner T, then
re-wrapped — corrupting the value (a multi-field payload's first field clobbered
by the has_value flag, or a `?T` arg silently null) or failing LLVM
verification. lowerStructLiteral now builds the inner T, materializes it, and
wraps via coerceToType; lowerVarDecl's previously-UNCONDITIONAL optional wrap is
guarded so an already-`?T` value isn't double-wrapped. Fixed across var-decl,
arg, return, nested field, reassignment, and array-element contexts.
(B) `#get` accessors are now reachable through an optional chain (`obj?.getter`):
lowerOptionalChain dispatches the getter via a synthetic receiver, and
expr_typer types `obj?.getter` through a shared getterReturnTypeOnDeref helper
(handles `?T` and `?*T`, value and pointer optionals, and generic-instance
getters like List.len). The `#set` write side through `?.` is intentionally left
matching real-field behavior (optional-chain assignment unsupported).
Regression tests: examples/optionals/0906 (struct-literal → optional) and 0907
(accessor through chain). issues/0160 marked RESOLVED with the corrected root
cause.
A method `name :: (self: *T, value: V) #set { ... }` (or `=> expr;`) is the
write counterpart of a `#get` accessor: `obj.name = rhs` dispatches to it as
`obj.name(rhs)` when no real field matches. Plumbed parallel to `#get`:
- lexer/token `#set`; `FnDecl.is_set` + `Function.is_set`; parsed in the same
marker slot as `#get` (no return type, exactly self + one value param).
- get+set coexistence: a setter registers/mangles/dispatches under an effective
`name$set` name (`$` is illegal in sx identifiers, so unmistakable), keeping a
same-name `#get` under the plain `name`. Resolution is declaration-order-
independent: a plain read query picks the non-setter, a `name$set` write query
picks the setter (accessorEffName / accessorNameMatches / structMethodFn).
- write dispatch in lowerAssignment via tryLowerPropertyAssignment: plain assign
synthesizes `obj.name$set(rhs)`; compound `OP=` is get-modify-set and
evaluates the receiver EXACTLY ONCE (bound to a synthetic local); read-only
(#get-only) and write-only (#set-only + compound) emit clear diagnostics; a
real field of the same name still wins. Multi-assign property targets dispatch
the setter too (tryLowerPropertyStore, via a pre-lowered-Ref binding).
Payoff: List gains a `len` #set, so `xs.len = n` works; the `.items.len = N`
write workarounds in sched.sx + ui/* + platform/* revert to `xs.len = N`.
issues/0160 records an optional-chain interaction surfaced by the review (a
pre-existing `?T` value-optional read miscompile that blocks getter-through-`?.`).
items is now a []T slice whose .len IS the live element count (cap = allocated
capacity), so a List iterates directly: `for xs.items (e) { ... }`. A
`len :: (self) -> i64 #get => items.len` accessor keeps `xs.len` reads working;
`.len` WRITES become `.items.len`. List stays 24 bytes (`[]T`=16 + cap=8).
- list.sx: append/ensure_capacity/deinit rewritten for the slice backing. deinit
guards the free on `cap > 0` (true ownership) and resets via explicit
ptr=null/len=0 (a `.{}` slice assignment yields a garbage len; `.[]` is the
empty-slice literal but can't be assigned to a generic []T — both worked around).
- Compiler coupling updated: comptime_vm makeStringList/readStringList write/read
items as a {ptr,len} fat pointer at field 0 + cap at field 1; control_flow
listView views an `items: []T` slice (keeps the legacy {[*]T,len} shape too).
- Migrated List `.len` writes to `.items.len` in sched.sx + ui/{render,pipeline,
glyph_cache} + platform/{sdl3,android,uikit}.
- Snapshots: List's type-table layout changed → ~40 .ir + memory/0800 (items now
prints as a slice) regenerated; diagnostics/1183 retargeted to a genuine
many-pointer (xs.items is a slice now). Example memory/0840 locks for-each.
A method declared `name :: (self: *T) -> R #get => expr;` is invoked via
no-paren field syntax (`obj.name`) instead of `obj.name()`. It is an ordinary
method (registered `Type.method`, flagged is_get); field-access lowering and
inference dispatch to it when no real field of that name exists, by synthesizing
a no-arg `obj.name()` call routed through the normal call path (so receiver
address-of and generic binding are reused).
- Lexer/token: `#get`. Parser: parsed after the return type in parseFnDecl;
hasFnBodyAfterArrow treats it as a body marker so struct-body methods parse.
- Resolution: getAccessorFor handles a generic-struct instance and a plain
struct. A REAL field of the same name wins (a getter never shadows stored
data). An explicit postfix-deref receiver (`p.*.getter`) dispatches on the
inner pointer so it takes the working auto-deref path.
- Works on plain + generic structs (incl. getters returning the type param),
in expressions/conditions/args/loop-bounds, chained, and via a pointer
receiver. Examples: types/0196 (basic) + types/0197 (stress).
Known narrow limitations (clean errors / workarounds, not silent): a getter
RESULT used directly as a method/getter receiver (`o.gi.dbl`) errors — bind it
to a local first; a getter named `len`/`ptr` returning non-i64 mis-infers
(the .len/.ptr builtin-field shortcut).
emitSubslice handled a struct (slice/string) base and an array base, but a
many-pointer [*]T base is an LLVM pointer kind — it fell through to the else arm
that mapped the result to LLVMGetUndef(slice_ty), so a slice of a many-pointer
(mp[lo..hi]) had a garbage .len/.ptr and iterating it segfaulted.
Add a LLVMPointerTypeKind branch: the base value IS the data pointer, so GEP by
lo and len = hi - lo (the caller supplies the bound; no length is read from the
unbounded pointer). An open-ended mp[lo..] has no resolvable upper bound (a [*]T
carries no length), so lowerSliceExpr now diagnoses it instead of emitting a
.length op that yields garbage.
A List (whose items is [*]T) is now iterable with for items[0..len] (e);
applied in Scheduler.deinit. Regressions: examples/types/0195 (valid slice +
List for-each) + examples/diagnostics/1192 (open-ended rejection).
A plain union initialized with a struct literal (b : Overlay = .{ f = 3.14 })
silently miscompiled — it fell through the generic struct-literal path
(getStructFields returns empty for a union), building a malformed structInit
whose overlapping zero-fill clobbered the named member, so it read back 0.0
(and a type-pun read segfaulted).
lowerStructLiteral now detects a plain-union target and dispatches to a new
lowerUnionLiteral, which writes each named member into a union-sized slot via
the same lvalue resolver the u.member = v assignment path uses, then loads the
union value back. Validity: the named members must share one arm — a single
direct member, or several promoted members of the same anonymous-struct variant.
Overlapping members, members from different arms, and positional union literals
are rejected with a diagnostic (no silent last-wins); an empty .{} yields an
undefined union (matching the --- form).
specs.md updated. Regressions: examples/types/0194 (valid forms) +
examples/diagnostics/1191 (overlap rejection).
UFCS generic overload resolution (issue 0157 follow-ups):
- P1-a: call planning (calls.zig) used the last-wins fn_ast_map winner
while lowering reselected by receiver, so the planned result type
could disagree with the dispatched function and misbox the result.
Both now share selectUfcsGenericByReceiver(.., fd0).
- P1-b: selection scanned module_decls globally, flagging a
transitively-hidden same-named overload as a false ambiguity. Now
two-tier: directly-visible authors first (ambiguity only among
those), global fallback for receiver-reachable namespaced methods
(e.g. Task.cancel) that defers to fd0 on a hidden tie.
- P2-b: boolean specificity tied *$T with *Box($T). Now peels pointer
layers so the structurally-narrower receiver wins.
Scheduler (sched.sx):
- P1-c: a second concurrent Task.wait overwrote the single waiter slot
-> silent deadlock. Now one-awaiter-per-task loud abort.
- P2-c: sleep(negative) rewound the monotonic virtual clock. Rejected
loudly.
(P2-a, non-generic-winner-hides-generic, did not reproduce -- the
non-generic arm already falls through.)
Regressions: examples/generics/0218 (receiver specificity +
plan/lowering agreement), examples/concurrency/1818 (negative-sleep
abort), 1819 (double-wait abort). Suite green 758/0.
0156 Part 1: a single-type generic $R (parsed as comptime_pack_ref)
used as a type-arg in a pack-fn body (Box($R), size_of(Box($R))) hit a
missing arm in resolveTypeWithBindings -> .unresolved -> LLVM panic.
Fix: mirror resolveTypeArg's comptime_pack_ref arm (look up
type_bindings, else a loud diagnostic). Regression: examples/generics/0216.
(Part 2 -- deferred .. spread crashes -- reframed OPEN/non-blocking.)
0157: a user generic ufcs method whose name collides with a stdlib
re-export resolved via last-wins fn_ast_map with no receiver filtering,
so the wrong overload won, $R never bound, and .unresolved reached LLVM.
Fix: selectUfcsGenericByReceiver enumerates all module authors, keeps
the receiver-binding ones, picks the most receiver-specific (concrete >
bare $T), dedups re-exports, and flags a genuine tie as a deterministic
'ambiguous -- qualify' diagnostic. Regression: examples/generics/0217.
Assigning null/--- to a struct field picked up a leaked enclosing
target_type (the function's return type, set for the whole body), so
constNull/constUndef built a whole-struct-typed value. The oversized
store overran the field's slot and clobbered the saved frame pointer,
so the function returned to 0x0. Surfaced building a by-value-returned
struct whose array field precedes a pointer field (Scheduler.init()).
Fix: add null_literal/undef_literal to the needs_target switch in
lowerAssignment so the field's own type is used. Regression:
examples/types/0193-types-sret-array-before-pointer.sx.
Move examples/*.sx and their expected/ snapshots into per-category
subfolders (examples/<category>/...). Folder = leading filename token,
with ffi-objc/ffi-jni kept whole; filenames are unchanged. The corpus
runner and LSP sweep now discover each category's expected/ dir, while
issues/ stays flat. Example 1058's repo-root-relative companion import
is made file-relative. Path strings embedded in 164 snapshots were
regenerated (path-only changes). Test-layout docs in CLAUDE.md updated.
Protocol method declarations now declare their receiver explicitly as the first
parameter — 'self: *Self' (or 'self: Self') — matching the impl method signature,
instead of the old implicit-receiver form where the listed params were only the
extra args. That asymmetry repeatedly caused confusion over whether the first
param was the receiver or an argument.
The parser validates the first param is 'self' typed Self/*Self, then strips it,
so all downstream lowering and the dispatch ABI are unchanged (impl blocks and
call sites are unaffected). A protocol method missing the receiver is now a parse
error.
Migrated all 129 protocol method signatures across library + examples (+ one
inline-sx test in sema.zig) to the explicit form. Updated specs.md + readme.md.
New: examples/0418-protocols-explicit-receiver.sx (feature),
examples/1190-diagnostics-protocol-missing-receiver.sx (negative/diagnostic).
size_of(sel.Selection) and the other reflection builtins rejected a
module-alias-qualified type: in argument position it parses as a .field_access
expression (not the dotted .type_expr a declaration produces), and neither
isStaticTypeArg nor resolveTypeArg had a .field_access arm. Add both: a pure
namespace-decl scan in isStaticTypeArg, and resolution via namespaceAliasTarget
+ resolveNominalLeaf in the target module context in resolveTypeArg (mirroring
the value-position lowerFieldAccess path). No fabricated-stub fallback.
Regression: examples/0192-types-size-of-qualified-alias.sx
A struct/tuple/?T with a void field crashed the compiler: the field lowered to
LLVM's unsized 'void' type, which traps getTypeSizeInBits. Lower a void field to
a SIZED zero-byte [0 x i8] (fieldLLVMType) so the enclosing aggregate stays sized
with identical element indices, and skip inserting a value for a void field in
emitStructInit (the i64 placeholder would type-mismatch the [0 x i8] slot and
corrupt the aggregate constant -> runtime bus error). Future(void) now works.
Regression: examples/0190-types-void-struct-field-zero-sized.sx
A comparison with int-vs-float (or two float widths) operands emitted cmp on
the raw operands with no promotion, unlike the arithmetic arms -- producing a
mixed-type compare the LLVM verifier rejects / mis-evaluates. lowerBinaryOp now
coerces each operand to the promoted common type (from arithResultType) via
coerceToType (SIToFP / FPExt) for the ordering/equality arms when the promoted
type is a float, so LLVM gets a well-typed fcmp.
Regression: examples/0189-types-int-float-compare-promote.sx
A *self method called directly on arr[i] (or a deref place) fell through to an
alloca+store-of-value, so the callee mutated a throwaway copy and the live slot
was never written. fixupMethodReceiver now takes the real address of
.index_expr/.deref_expr receivers via lowerExprAsPtr (normalized to *T),
mirroring the explicit-argument path. A comptime-pack index (xs[i] where xs is
a pack) is excluded -- a pack has no runtime storage to address -- so it keeps
flowing through the general copy path.
Regression: examples/0188-types-method-array-index-receiver.sx
A local 'error { ... }' set with the same name as an imported one collapsed
onto the import, losing its own tags, because registerErrorSetDecl deduped via
the flat findByName path while struct/enum/union use E6a per-decl identity.
Build the .error_set TypeInfo (new buildErrorSetInfo helper factored from
resolveInlineErrorSet) and intern via internNamedTypeDecl with shadowNominalId;
reserve a distinct shadow slot in scanDecls; consult per-decl type_decl_tids in
namedRefTid before findByName. The inline/anonymous findByName short-circuit is
preserved.
Regression: examples/1059-errors-same-name-error-set-own-wins.sx (moved from
issues/0134).
A bodiless #builtin with a $T: Type param routes through monomorphization.
When resolveBuiltin returned null for an unrecognized name, the builtin-body
branch fell through to ensureTerminator's constInt(0) -- a silent-fallback
default the CLAUDE.md REJECTED PATTERNS forbid. Emit a loud
'error: unknown #builtin <name>' diagnostic instead.
Regression: examples/1189-diagnostics-unknown-builtin.sx
A program with no 'main' reached the JIT entry-point call with a garbage
address (ORC reports lookup success but leaves main_addr degenerate), then
called it -> SIGSEGV. Add a pre-JIT entry-point check in main.zig that emits
'error: no main function found' and exits non-zero before codegen, plus a
defensive main_addr==0 guard in target.zig runJITFromObject as a backstop.
Regression: examples/1188-diagnostics-run-no-main.sx
inferGenericReturnType resolved a generic call's return-type AST ($R, !E) in
the CALL-SITE module context. For a re-exported fn the error-set name (LE /
IoErr, re-exported as LE :: lib.LE) resolved through the call-site alias to a
TypeId NOT tagged .error_set, so the planned result was a tuple whose last
field wasn't an error set — errorChannelOf saw a plain tuple and the value-
failable's ! channel was lost (try/or rejected it / built a malformed i1 PHI).
monomorphizeFunction already pins the source to the fn's defining module
before resolving the return type; inferGenericReturnType did not, so the
planned call-result type disagreed with the instance's real signature. Fix:
pin the source to fd.body.source_file around the return-type resolution
(binding-build stays in the call-site context — its args are typed there).
Regression test examples/1058-errors-reexport-value-failable-channel.sx
(+ companion lib.sx). Suite green 732/0.
LLVM rejects a sub-byte atomic memory access (must be byte-sized), so
Atomic(bool) — bool lowers to i1 — failed verification on load/store. The
atomic emitters in src/backend/llvm/ops.zig now perform a sub-byte access in
its byte storage type (i8) and trunc/zext the value at the boundary (new
atomicByteType helper: i8 for .bool, null otherwise). rmw/cmpxchg are left
as-is on purpose — a bool rmw/CAS is rejected at the sx level (integer-only),
so a sub-byte element never reaches those emitters.
Regression test examples/1705-atomics-bool-byte-promoted.sx. Suite green 729/0.
Unblocks Future.canceled: Atomic(bool) in the B1.2 async layer.
The generic-inference engine could not bind a $T from a generic-struct
argument head. Four gaps, all on the inference + UFCS dispatch path:
- extractTypeParam / matchTypeParam(Static) gained a parameterized_type_expr
arm: recover the arg instance's recorded per-param bindings
(struct_instance_bindings + the template's ordered type_params via
struct_instance_author) and recurse positionally, so $T binds from
Box($T) <=> Box(i64) like it does from []$T <=> []i64. This also fixes
the pointer case — *Box($T) recurses into its Box($T) pointee.
- The pointer_type_expr arm now falls through to match the pointee against a
non-pointer arg (auto-address-of: a *Box($T) param accepts a by-value
Box($T), e.g. the UFCS receiver b.m()).
- ExprTyper.inferType gained a .lambda arm building the closure type from the
lambda's annotations, so the UFCS binder (which types args from the raw AST
before they are lowered) can bind a Closure(..) -> $R from the worker's
declared return type.
- A pack UFCS target (worker: Closure(..) -> $R, ..$args) now routes through
the same lowerPackFnCall the direct call uses, with the receiver spliced in
as args[0] (lowerPackFnCall reads only call_node.args, never the callee).
Regression tests: examples/0214 (direct + UFCS closure-return pack) and
examples/0215 (by-value / pointer / multi-param / nested / UFCS-auto-ref
generic-struct-head inference). Suite green 728/0.
Threads an `Io` capability onto `Context` exactly like `Allocator`: a
`protocol #inline` whose process-wide default is a stateless `CBlockingIo`
(the mirror of `CAllocator`), installed in `__sx_default_context`.
Library (library/modules/std):
- core.sx: `Io` protocol (spawn_raw / suspend_raw / ready / poll / now_ms /
arm_timer) + `SpawnOpts` / `PinTarget` / `ParkToken`; `Context` gains an
`io: Io` field LAST (allocator stays index 0, data stays index 1).
- io.sx (new): `CBlockingIo` + `impl Io` (blocking M:1 semantics — now_ms is
a real monotonic clock, the rest are no-ops/0; suspend never called);
`Future($R)` { value; state: FutureState; err: IoErr; park; task; canceled:
Atomic(bool) } with `Value :: R`; the async ergonomic layer
`async` / `async_void` / `await` (value-carrying `(R, !IoErr)`) / `cancel`.
Built with the verified `= ---` + field-assign + `Closure(..$args) -> $R` +
`..$args` idiom (NON-void $R only — Future(void) is deferred per issue 0150).
- std.sx: re-export the Io surface + the io.sx tail.
Compiler (src/ir):
- protocol.zig `emitDefaultContextGlobal` + comptime_vm.zig
`materializeDefaultContext`: both materializers of `__sx_default_context`
now build the inline CBlockingIo->Io vtable (7 words) at the new field.
- stmt.zig `lowerPush`: `push Context.{...}` now INHERITS omitted fields from
the ambient context (seed the slot from current_ctx_ref, overwrite only the
literal's named fields) — correct capability-bag semantics, so the partial
`push Context.{ allocator = X }` sites don't zero a null `io` vtable.
- protocols.zig + lower.zig + error_analysis.zig: record protocol-impl method
names so the "declared `!` but never errors" lint skips a conforming impl
whose `!` is dictated by the protocol contract (e.g. Io.suspend_raw).
37 `.ir` snapshots regenerated: layout-only (the Context type now carries the
Io field, shifting type-table numbering); no stdout/stderr/exit changes.
The blocking Io + now_ms + Future/async work when `async` is called with the
receiver passed explicitly; the user-facing UFCS form `context.io.async(...)`
is blocked on a separate UFCS generic-inference bug (filed next).
Suite: 726 ran, 0 failed.
"pure" universally means side-effect-free (GCC __attribute__((pure)),
FP purity, D's pure) — the opposite of a register-clobbering context
switch. The concept is "naked": no compiler-generated prologue/epilogue,
body is raw asm that emits its own ret. That is the established term
everywhere (LLVM's naked function attribute — which we literally emit —
plus Zig callconv(.naked), Rust #[naked], GCC/Clang __attribute__
((naked))). Rename the keyword + everything keyed off it so concept,
surface, field, and the emitted LLVM attribute all agree.
- ast.zig: ABI enum variant pure -> naked (+ doc).
- parser: accept abi(.naked); error text updated.
- IR Function.is_pure -> is_naked; type_resolver/decl/generic/pack/
emit_llvm references updated; diagnostics say abi(.naked).
- examples 1800-1803 renamed *-pure-* -> *-naked-* (source + expected/
snapshots; .ir/.exit/.stdout/.stderr are byte-identical — the emitted
IR is unchanged, only the keyword spelling differs).
- docs (PLAN-FIBERS, CHECKPOINT-FIBERS, PLAN-POST-METATYPE, the design
roadmap, the compiler-API checkpoint/design) updated; the naming
rationale now records why .naked over .pure.
No semantic change — pure cosmetics. Suite green (725/0).
Adversarial review of B1.0b found a param-bearing abi(.pure) function
emitted invalid LLVM ("cannot use argument of naked function" — loud
verifier error, not silent) because the param-alloca loop spilled the
args to stack slots, which a naked function cannot have.
Fixed forward — this ENABLES the B1.3 context-switch use case rather
than rejecting it: gate the param-alloca loop on fd.abi != .pure in
decl.zig (both body-lowering paths) and generic.zig. A naked function's
args stay in their ABI registers and are read directly by the asm body
(e.g. swap_context reads from/to from x0/x1); the LLVM args are
declared-but-unused, which the verifier allows.
examples/1803-concurrency-pure-asm-param.sx: naked add(a, b) reads x0/x1
(add x0, x0, x1; ret) -> 40 + 2 = 42. aarch64-pinned.
Pack abi(.pure) (variadic + naked — nonsensical, can't read a runtime
pack from registers) left unsupported: pack.zig's param loop is
intertwined with comptime-param/#insert handling, so that case still
hits the loud verifier error. Documented in the checkpoint.
Also updates PLAN-FIBERS / CHECKPOINT-FIBERS for B1.0 completion.
B1.0 complete. Suite green (725/0).
Flip the B1.0a emit bail to real emission. The emit_llvm declaration
pass now adds LLVM's naked + noinline + nounwind attributes for an
is_pure function and skips frame-pointer=all (incompatible with a
frameless function); Pass 2 emits the body normally, and the naked
attribute makes the backend emit it verbatim (the inline asm + its own
ret) with no prologue/epilogue.
IR shape verified:
; Function Attrs: naked noinline nounwind
define internal i64 @answer() #0 {
entry:
call void asm sideeffect "...ret...", ""()
unreachable
}
The caller invokes it as an ordinary () -> i64 call (.pure is
call_conv == .default).
- examples/1800-concurrency-pure-asm.sx: now green, aarch64-pinned
(.build macos) -> exit 42 + .ir snapshot.
- examples/1801-concurrency-pure-generic.sx (renamed from -bail): the
generic .pure now emits a correct naked answer__i64 (exit 42),
proving generic.zig produces a naked body, not a framed one.
- examples/1802-concurrency-pure-asm-x86.sx: x86_64 cross sibling
(.build x86_64-linux, ir-only here); .ir locks naked + movl $42,%eax.
- unit test in emit_llvm.test.zig asserts the naked attribute is present
and frame-pointer absent on an abi(.pure) function.
Suite green (724/0).
Adversarial review of dd363ca found is_pure was set only at the two
declareFunction decl sites. Generic monomorphization (generic.zig) and
pack expansion (pack.zig) create the IR Function via a different path
and left is_pure false, so a generic abi(.pure) instance bypassed the
emit bail and silently shipped a framed body — it returned 42 but
leaked the prologue's stack adjustment (the exact SP-in != SP-out
corruption the lock exists to prevent).
Both paths now set is_pure and route .pure bodies through the asm-only
+ unreachable cap, mirroring the decl path. Locked by
examples/1801-concurrency-pure-generic-bail.sx (generic .pure reaches
the loud bail).
The review's other CRITICAL (a .pure lambda) is a false positive:
isLambda's return-type scan (parser.zig:3652) breaks on the abi
keyword, so a .pure lambda is unparseable and parseLambda's abi
handling is never reached. Latent isLambda/parseLambda inconsistency,
not a B1 concern.
Suite green (723/0).