An `xx <int>` argument to a variadic `format`/`print` (a comptime `..$args`
pack) segfaulted when the call was inside an imported-module function. Root
cause: lowerPackCall lowered each pack arg with whatever self.target_type was
set to from the surrounding context. A bare arg is unaffected (inferExprType
ignores target_type), but `xx <expr>`'s result type IS target_type — so
`format("…", xx i)` inside a `-> string` fn cast the int to `string`,
monomorphized __pack_string, and ABI-coerced the 4-byte int as a 16-byte string
fat pointer → corruption. Inline it worked only because target_type was null
there; the imported-module path left it set.
Fix: save/clear/restore self.target_type around the pack-arg lowering loop. A
pack arg is independently typed — comptime `..$args` auto-boxes to Any; a value
pack takes its declared element/protocol type — never a leftover outer target.
examples/242-xx-any-pack-cross-module.sx (+ companion fmt.sx) is the regression.
issues/0057 marked resolved. Unblocks ERR E3.3 (the trace.sx formatter formats
frames with `xx frame`).
Gates: zig build, zig build test, bash tests/run_examples.sh (279 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
Connect the E3.1 buffer to codegen. Push sites: `raise` (always escapes — push
before cleanup) and `try`'s propagation branch (the failure that escapes to the
caller). Clear sites: `catch` handler entry (via runCatchBody, error path only),
the `or value` terminator's failure branch, and a destructure that binds a
failable's error slot — so an absorbed failure leaves no residue.
Helpers in lower.zig: emitTracePush / emitTraceClear (call getTraceFids, no-op
when traces are off), tracesEnabled (opt_level == .none/.less — `sx run`
defaults to -O0, so on in dev; .default/.aggressive are release → off, zero
overhead), and placeholderTraceFrame (a nonzero u64 until DWARF/E3.0 supplies
real PCs and E3.3 resolves them).
Verified end-to-end via a #foreign sx_trace_len probe: catch/or/multi-slot-
destructure drive len back to 0; release (--opt default) emits no push/clear at
all (debug showed a residual where release showed 0).
examples/241-error-trace-buffer.sx is a focused regression (white-box: reads
sx_trace_len directly, pending E3.3's public trace.print_current).
KNOWN GAP (documented, deferred to the E1.8 flow-check binding-site work): a
single-binding capture of a PURE failable (`er := pure_failable()`, not a
comma destructure) goes through lowerVarDecl, not lowerDestructureDecl, so it
doesn't clear — the trace over-retains until the next absorbing site. Harmless
today (nothing reads the buffer at function exit yet) but wrong per spec.
Gates: zig build, zig build test, bash tests/run_examples.sh (278 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
Add the trace buffer that raise/try push to and catch/or/destructure clear,
following the JNI-TLS precedent exactly (a thread_local IR global doesn't work
under the ORC JIT, which doesn't init TLS for AddObjectFile'd objects).
- library/vendors/sx_trace_runtime/sx_trace.c: a `_Thread_local` fixed-cap ring
(32 frames) of opaque u64s + C API (push / clear / len / truncated /
frame_at). Overflow keeps the newest CAP frames and latches `truncated`
(Zig-style); frame_at returns oldest-to-newest. The frame is opaque — the
E3.3 formatter dispatches on context (PC at runtime, packed (func_id, offset)
at comptime).
- build.zig: link the .c into the compiler so the JIT resolves sx_trace_* via
dlsym (and so the unit test links against it).
- src/runtime_trace.test.zig: exercises push / overflow-survives-newest / clear
/ len / truncated / ordering against the linked C — grounds the buffer logic
without shipping throwaway sx builtins.
- lower.zig getTraceFids(): lazily declares the sx_trace_push/clear externs +
sets needs_trace_runtime. Declared now; the raise/try push sites and the
absorbing clear sites get wired at E3.2.
- core.zig: auto-injects the .c as a #source for AOT when needs_trace_runtime,
mirroring the JNI env runtime.
Gates: zig build, zig build test (incl. the new buffer tests), bash
tests/run_examples.sh (277 passed; no codegen change this step — lone failure
is the user's uncommitted 213-canonical-map pack WIP).
`{}` on an error-set value printed `<?>` (any_to_string had no error_set
category). Now it renders the tag name (`BadDigit`), reusing the existing
any_to_string dispatch.
Pieces:
- New `error_tag_name_get` IR op (UnaryOp): tag id -> name. Lowered from a new
`error_tag_name(e) -> string #builtin` (std.sx). Handled across inst.zig
(op def), print.zig, interp.zig (comptime: tags.getName), and emit_llvm.zig.
- emit_llvm getOrBuildTagNameArray: an always-linked `[N x {ptr,i64}]` global
of tag names indexed by global tag id (the TagRegistry namespace, slot 0 =
""). error_tag_name_get zext's the u32 tag value and GEPs into it. Built once;
not trace-gated, so it works in release too (per the spec's "tag-name table
always shipped").
- resolveTypeCategoryTags gains an `error_set` category so the
`case error_set:` arm in any_to_string matches; that arm coerces the Any to
u32 (`xx val`) and calls error_tag_name. (cast(type) didn't recover the tag
id for error-set values; the u32 coercion does.)
examples/240-error-tag-interpolation.sx: bound tags + a catch-bound tag print
their names. Regenerated ffi-objc-call-06-sret-return.ir — pure block-renumber
drift from adding one if-arm to the shared any_to_string (verified
semantically identical after collapsing block numbers).
Gates: zig build, zig build test, bash tests/run_examples.sh (277 passed; lone
failure is the user's uncommitted 213-canonical-map pack WIP).
Add validateMainSignature (lowerRoot Pass 4a). main must take no parameters
and have a single-slot return — void, an integer (POSIX exit code), or `-> !`
/ `-> !Named` (the error tag rides the single return register, which the JIT's
`() -> i32` main call handles directly). Other shapes are now clean
diagnostics instead of silent miscompiles:
- `main :: () -> string` previously SEGFAULTED (the i32 return register was
read as a string) — now a clear "return type must be void, an integer, or
`!`" error.
- `main :: (x: ...)` previously ran silently (param ignored) — now rejected.
- `main :: () -> f64` / non-failable tuple / etc. — rejected.
The value-carrying failable `-> (T, !)` is rejected for now: its multi-slot
{value, error} return ABI-mismatches the entry-point call and segfaults. That
shape needs the E4.2 entry-point wrapper (gated on E3 return traces); rejecting
loudly beats miscompiling. `-> !` (no value) IS accepted — single-slot, works
today (success exits 0; a raise exits nonzero, trace/tag story pending E3).
examples/239-main-signature-reject.sx covers the `-> string` rejection (exit 1).
Accepted shapes are exercised elsewhere (238 for integer-exit truncation; the
existing suite for void/int main). Gates: zig build, zig build test, bash
tests/run_examples.sh (276 passed; lone failure is the user's uncommitted
213-canonical-map pack WIP).
A non-failable integer `main :: () -> T` must exit with its return value
truncated to u8 (matching C main / the OS exit-status byte), so `sx run`
(JIT) and an AOT binary agree. runJITMain clamped instead: any value outside
0..255 returned exit 1, so `return 1105` exited 1 (not 81), `return -1` exited
1 (not 255), and `return 256` exited 1 (not 0).
Fix: bit-cast the i32 return to u32 and @truncate to u8 — negatives wrap as
their two's-complement low byte rather than being clamped. The AOT path
already gets OS truncation, so it was already correct; this makes JIT match.
examples/238-main-exit-truncation.sx returns 1105 -> exit 81. Values <=255
(42, 200) still pass through unchanged.
Gates: zig build, zig build test, bash tests/run_examples.sh (275 passed; the
lone failure is the user's uncommitted 213-canonical-map pack WIP).
A defer or onfail body runs while the block/function is already exiting, so it
has no target to transfer control to. `raise` was already rejected (E1.3); this
adds the rest of the locked set — `return` / `break` / `continue` / `try`.
In parseStmt, the return/break/continue/try parse sites now call a new
rejectInCleanup() helper, gated on in_onfail_body || in_defer_body (the existing
flags, whose doc-comments already scoped this follow-up). The ban is transitive
through nested catch bodies and loops, but parseLambda clears both flags for the
closure body — a closure is its own function boundary, so a `return` from a
closure created inside a cleanup body stays legal. The diagnostic names the
cleanup kind ("an `onfail`" / "a `defer`").
examples/237-cleanup-body-restrictions.sx covers the rejected forms (exit 1);
six inline parser tests cover each banned exit, the transitive-through-loop
case, the closure-boundary exception, and flag-restore after the defer.
Note: examples/213-canonical-map.sx is the user's uncommitted heterogeneous-
variadic-pack WIP (prints 40 vs expected 42); it fails on the committed parser
too, independent of this change, and is left unstaged.
Gates: zig build, zig build test (288 pass), bash tests/run_examples.sh (all
green except the unrelated 213 WIP).
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.
The preceding parser fix (parenthesized match-arm value vs payload capture)
fully enables `catch e == { case .X: (tuple) }` — both scalar and tuple arm
values. Tuple literals in statement/binding position already worked, so the
match-body form runs end-to-end.
Add a `classify` to examples/235 exercising multi-value catch match-body with
per-tag value-tuple arms; exit 164 -> 170. Regenerate the snapshot.
(Corrects an earlier note that wrongly claimed a separate "issue 0059" blocked
the tuple match-body form — no such issue exists; the capture-parse bug was the
whole problem.)
Gates: zig build, zig build test, 273/273 examples.
A match arm `case PAT: (expr)` — e.g. `case 0: (5)` — failed to parse:
parseMatchBody unconditionally consumed an `(` after `case PAT:` as a
payload-capture `(ident)`, so a non-identifier first token produced
"expected capture name".
Disambiguate: treat `(` as a capture only when it encloses exactly a lone
identifier — `( ident )` — via a new isLoneIdentParen() helper (peekTag-based
two-token lookahead). Otherwise the parens belong to the arm-body expression.
Payload capture (`case .b: (v) { ... }`, examples/128) still binds.
This fixes the scalar paren arm value (`case 0: (5)` now parses and runs).
The tuple arm-value form (`case .X: (a, b)`) additionally needs a tuple
literal in statement/binding position, tracked separately as issue 0059.
Tests: two inline parser unit tests (paren arm value is not a capture; lone
`(ident)` still binds). Gates: zig build, zig build test, 273/273 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.
Fills the E0.3 coverage gaps E0.1/E0.2's inline tests hadn't hit and adds
an end-to-end integration parse. Test-only; no production code change.
- `try` in statement position (`try must_init();`).
- `try` over a parenthesized or-chain (`try (foo() or boo())`) — distinct
from `try foo() or try boo()`.
- `or` value-terminator (`parse(s) or 0`).
- Integration: a full `parse :: (s) -> (s32, !ParseErr) { onfail / try / or /
catch / if { raise } / return }` — asserts the trailing `!ParseErr`, the
five body statement kinds, and that `in_onfail_body` is correctly scoped
(the later if-block `raise` is allowed).
Tests stay inline in parser.zig (consistent with the existing 24 + E0.1/E0.2
inline tests). 37 ERR parser tests total; every new AST node has a round-trip
test. zig build test (268) and 254/254 examples green.
Parser-only second step of the error-handling stream. No sema/codegen.
- token: 4 keywords — `raise`, `try`, `catch`, `onfail`.
- ast: RaiseStmt, TryExpr, CatchExpr {operand, binding?, body, is_match_body},
OnFailStmt {binding?, body}.
- parser:
- `try` is a unary prefix (binds tighter than `or`; right-recursive so it
stacks under `xx`/`@`/etc).
- `or` is already left-associative (precedence-climbing loop) — no change.
- `catch` is a postfix with four body shapes (no-binding block / block /
bare-expr / `== { case }` match-body, the latter reusing parseMatchBody
with the binding as subject).
- `raise EXPR;` and `onfail [e] { } | onfail EXPR;` statements; `error`
parses in expression position so `raise error.X` works; raise rejected
in expression position and inside an onfail body (in_onfail_body flag).
- consumer-aware `|>`: pipes the LHS into the head call through a
try/catch/or wrapper, preserving the wrapper.
- print: printExpr + match-arm printing for round-trips (anyerror!void to
break the printExpr<->printMatchArms inferred-error-set loop).
- sema/lsp: exhaustive switch arms for the 4 nodes + 4 keyword tokens.
- tests: ~22 inline parser tests (precedence, all catch forms, both
rejections, pipe cases, round-trip prints incl. match-body).
zig build, zig build test (264), and 254/254 examples green.
Reuse the compiler's lowering pass instead of re-implementing its checks
in sema. A module can't be lowered standalone — lowering only type-checks
functions reachable from a root — so the open file alone misses errors
like a *Move passed into a by-value method parameter. Drive the workspace
entry (main.sx) through parse → resolveImports → lowerToIR, then attribute
each diagnostic back to its file via source_file and publish per file
(clearing files whose errors are gone).
Runs on didOpen/didSave (disk-based); sema stays the live per-keystroke
layer. Advertise textDocumentSync.save so the editor sends didSave.
collectProjectDiagnostics is split out (transport-free) and covered by a
hermetic temp-project test.
Bring the lower.zig call-argument check to the LSP analyzer so the
`*T`-where-`T` mismatch (a `for xs: (*m)` capture or a `*T` parameter
forwarded into a by-value parameter) is reported inline as you type,
not only at build time.
The fn-signature registry resolved parameter types with the shallow
Type.fromTypeExpr, which yields 'unresolved' for user structs, so the
argument type never matched the parameter. Resolve params through the
registry-aware fieldType instead (as the param symbols already do).
Restricted to direct identifier calls so args align 1:1 with params.
Add a regression test.
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.
Cmd+clicking a struct field / method / enum-variant at its own
declaration returned null, so nothing happened — while find-references on
the same token worked. Resolve a definition-site click to its own
location; the editor then surfaces references on a definition-click
instead of doing nothing. Member uses still resolve to their definition.
Add selfMemberDefAt + a regression test.
find-references only searched documents the editor had open, so asking
for references to a field from a file whose users were all closed
returned just the definition. Load every .sx under the workspace root
before matching so uses in unopened files are found too.
The LSP server's own tests were dormant: nested under the `lsp` struct in
root.zig, refAllDecls never reached them, and they had bit-rotted (stale
DocumentStore.init arity, an unaligned dummy io, fake /test/ paths that
no longer resolve). Reference the lsp files directly so their tests run,
give the doc-store tests a real Threaded io with bare paths, and fix the
stale extractIdentAtOffset expectation.
Extract referencesPayload from the transport so it is unit-testable, and
add tests covering cross-document field references, includeDeclaration,
the for-loop capture inlay hint, and workspace file loading.
The sema analyzer bound a for-loop capture with no type, so navigating
or hinting through it (m.flag, m: Move) failed. Instantiate generic
field types (legal_moves: List(Move)) and infer the capture's element
type from the iterable — List-like structs, slices, arrays, many-
pointers, and a pointer followed to its pointee. By-ref captures bind a
pointer to the element; range cursors bind s64.
Inlay hints now descend into struct method bodies and emit a type label
for the capture itself.
Now that 'context' resolves as an implicit global, accessing it inside a callconv(.c) function (an FFI callback/trampoline) would silently resolve — but the C ABI carries no implicit context parameter, so it's actually unavailable there. Sema now tracks the current function's calling convention and, for 'context' under callconv(.c), emits a specific diagnostic ('unavailable in a callconv(.c) function — pass what you need explicitly') instead of resolving it or saying 'undefined variable'.
context (the context system — context.allocator, context.data) was reported as an 'undefined variable'. It's now registered as a Context-typed global when Context is in scope, so the field chain (context.allocator) resolves too, with a builtins-list fallback when Context isn't present.
Members aren't symbols, so their uses were never recorded. Adds a member-reference list (declaration + uses) tracked during analysis: struct fields/methods and enum variants as declarations; field access, method calls, bare enum literals, qualified Type.variant, and match-arm patterns as uses. Spans are derived from the source-relative name slices; uses carry the owner type (via inferExprType, dereferencing pointers). find-references matches by (owner, name) across loaded documents, treating an unknown owner as a wildcard.
Verified: references for a field (legal_moves), a method (clear_valid_targets), and a variant (promote_rook — decl + comparisons + case patterns + struct-literal values across 5 files).
analyzeTopLevelDecl skipped struct_decl entirely, so identifiers used inside method bodies were never recorded as references — find-references (and reference-based features) missed method callers. Each method body is now analysed in its own scope. Verified: references to generate_legal_moves now include game.sx's call inside update_valid_targets.
cmd-clicking a definition (or any use) now lists all references. Same-file matches are precise (by symbol index); cross-file matches a top-level name across loaded documents. Advertises referencesProvider. Verified: references to a free function resolve across files (rules.sx def + internal calls + main.sx caller).
resolveStructTypeName returned null unless the variable's type was exactly a struct, so 'board.castling' (board: *Board) couldn't locate the Board declaration. It now also returns the pointee struct name for a pointer-to-struct, read from the resolved symbol type. Verified: board.castling navigates to board.sx's castling field.
Two gaps made 'piece := board.squares[move.from.index]' (board: *Board) <unresolved>: analyzeParams typed params with fromTypeExpr (bare-name only), so *Board / []T / *List params became null; and field_access only handled a struct value, not a *Struct. Params now resolve via fieldType, and field_access auto-derefs a pointer object (p.field on *T resolves on T). Regression test added.
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.
The cursor clause now matches the collection form's ': (capture)' — 'for 0..N: (i)' instead of 'for 0..N (i)'. The colon is required when a cursor is present; the no-cursor form 'for 0..N { }' is unchanged. Updated examples/200, the pack-index doc comment, and the spec.
event_position and translate_sdl_event matched on e.* / sdl.*; lowerMatch now auto-derefs a pointer subject, so 'if e ==' / 'if sdl ==' are equivalent (same load + tag-switch in IR). Pure cleanup.
KeyData.key was a raw u32 carrying SDL_Keycode values, so app code had to reinterpret it as SDL_Keycode (xx e.key) — a leaky, unchecked cross-platform cast only valid because the backend happened to be SDL. Add a neutral Keycode enum; translate_sdl_event maps SDL_Keycode to it via keycode_from_sdl. App code compares e.key == .escape with no platform type and no cast; a new backend maps its own native codes in one place.
(*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.
ev := events.ptr[i] (events := g_plat.poll_events()) was <unresolved> through three gaps:
1. Return types went through Type.fromTypeExpr, which only handles a bare type_expr — so any []T / *T / List(T) return became void. An impl method 'poll_events -> []Event' registered as void and, merged after the protocol's correct signature, clobbered it. resolveReturnType now uses fieldType.
2. Struct/protocol methods were never put in fn_signatures, so recv.method() and Type.static() return types never resolved. registerMethodSig now adds them by bare name (first-wins), which is what resolveCalleeName already assumed.
3. .ptr/.len field access was string-only (and string.ptr wrongly returned string_type); now handles slices/arrays and returns the proper many-pointer element.
4. Tagged enums (payload variants) were only a symbol, never in a lookup registry; now also recorded in enum_types so the name resolves as a type.
Net: events -> []Event, events.ptr -> [*]Event, ev -> Event. Regression test added; confirmed end-to-end via the LSP inlay hint.