The non-transitive C-import visibility gate (`isVisible(.c_import_bare)`)
only recognised the legacy `#foreign` body shape; a bare `extern` fn
(empty-block body + extern_export == .extern_) escaped via the
`body != foreign_expr -> return true` arm and was caught only by the
general isNameVisible gate, yielding the generic 'not visible' wording
instead of the C-specific 'C function not visible; add #import' one.
Now both lib-less spellings route to visibleOverEdges, and a library-
bound `extern LIB` (like a library-bound `#foreign LIB`) stays
unconditionally visible. This makes a future fn-decl `#foreign`->`extern`
migration byte-identical at this gate. Greens example 1228.
644 corpus / 444 unit, 0 failed.
The define path now honors the optional `export … "csym"` symbol-name
override (gap iii). declareFunction's rename branch fires for `export` too:
the extern stub is declared under the C name and the sx→C mapping recorded
in foreign_name_map. lazyLowerFunction then resolves the stub by that C
name (via foreign_name_map) so the body promotes into the C-named function
— emitting `define @triple_c` instead of `@sx_triple`. sx-side call sites
to the sx name resolve through the same map (verified: 5*5 prints 25).
example/1227 greens: the companion C calls `triple_c` and prints
call_triple(7) = 22. Bare export (1226) is unaffected (no rename → sx
name). Suite green (638 corpus / 443 unit). Phase 2 (`export`) complete.
`export` (define + expose) now lowers to a defined C-ABI symbol with
external linkage and no implicit sx context — the four export-gap
conditions in src/ir/lower/decl.zig:
- (i) linkage: force `.external` for `extern_export == .export_` on both
define paths (lowerFunctionBodyInto, lowerFunction), beside the
OS-called entry points.
- (ii) C ABI: promote call_conv to `.c` on the define paths and in the
declareFunction extern-stub cc.
- (iv) no ctx: funcWantsImplicitCtx returns false for any non-`.none`
modifier (extern AND export), so no `__sx_ctx` slot is prepended.
- force-lower: an `export` fn is a lowering root (like `main`) in
lowerMainAndComptime — its purpose is external consumption, so it must
emit a body even when no sx code calls it; otherwise lazy lowering
leaves it a bodiless `declare`.
example/1226 now builds + runs via the AOT corpus mode: the companion C
calls `sx_square` by name and prints 37 / 82. Suite green (637 corpus /
443 unit). The optional `export "csym"` rename (gap iii) is Phase 2.2.
Parser: a 'kw_extern' branch in the var-decl-with-type-annotation path
(beside #foreign) parses 'name : type extern [LIB] ["csym"];' into
VarDecl.is_extern/extern_lib/extern_name; the trailing diagnostic now
lists 'extern'. Lowering: registerTopLevelGlobal uses
extern_name orelse foreign_name orelse name for the C symbol and sets
is_extern = is_foreign or is_extern; globalInitValue returns null (no
initializer) for extern globals too.
examples/1225 green: '__stdinp : *void extern;' lowers to
'@__stdinp = external global ptr'; @__stdinp reads non-null. Suite
green (636 corpus / 443 unit).
Phase 1 done: extern functions (bare + rename) and data globals (bare +
rename) all work, behavior-equivalent to the matching #foreign form.
export (Phase 2), aggregates (Phase 3), docs + A->B gate (Phase 4)
remain. green commit.
parseFnDecl parses the optional [LIB] ["csym"] tail after the
extern/export keyword into FnDecl.extern_lib/extern_name (mirrors
'#foreign LIB "csym"'). declareFunction unifies the symbol-name
override: rename_c_name = foreign_expr.c_name (for #foreign) OR
fd.extern_name (for extern) -> declare under the C name and map sx->C
in foreign_name_map; the dedupe guard now covers extern too.
examples/1224 green: 'c_abs :: (n) -> i32 extern "abs";' resolves
c_abs to libc abs -> c_abs(-42) = 42. 1223 (bare extern) unregressed.
Suite green (635 corpus / 443 unit).
extern_lib is parsed + stored but not a linking driver — like
'#foreign libc', it references a lib; the #library decl + build flags
remain the separate linking axis (decision 4). green commit.
Route a bare 'extern' fn declare-only, exactly like a lib-less #foreign
import. Six edits in decl.zig, each mirroring an existing foreign_expr
guard so the empty-block placeholder body is never lowered:
1. funcWantsImplicitCtx: suppress the implicit __sx_ctx for .extern_
2. declareFunction: add is_extern_decl
3. ...and include it in the C-ABI calling-convention promotion
4. lazyLowerFunction: .extern_ -> declareFunction (declare-only)
5. lowerFunction: .extern_ in the declare-only guard
6. lowerFunctionBodyInto: never promote/lower an extern stub
examples/1223 now green: 'extern' abs lowers to 'declare i32 @abs(i32)'
(external linkage, C ABI, no ctx param) and the call resolves against
the default-linked libc -> abs(-7)=7, abs(42)=42. The 1.0b hand-authored
snapshot matched byte-exact (no regen). Suite green (634 corpus / 443
unit). green commit (makes the 1.0b xfail pass; adds no new test).
A tagged union (enum-with-payload) is laid out { tag, payload }, but a
direct member write `s.rect = payload` lowered to a payload-only store
(union_gep into field 1) with no tag store — the discriminant went stale,
so a later match/== took the wrong arm with no diagnostic (issue 0136).
The read path already distinguishes tagged unions (enum_payload/enum_tag);
the write path treated them like plain unions.
A variant is set via construction (`s = .variant(payload)`, which writes
both tag and payload). A direct member write can't safely set the tag (the
active variant isn't known at the write site), so it is now rejected with a
diagnostic pointing to construction. A new diagTaggedUnionVariantWrite guard
— reusing the shared fieldLvalueResolve matcher, applied at both store sites
(lowerAssignment, lowerMultiAssign) — fires only for a whole-variant write
on a tagged union. Plain `union` writes and nested sub-field writes
(`s.rect.w = ...`) are unaffected.
Resolves issue 0136. Tests: examples/0185 (rejected), 0186 (nested write +
construction still work). specs.md / readme.md updated.
Assigning a struct literal to a named-struct member of a plain union
(`u.b = .{ ... }`) lowered the RHS as .unresolved and tripped the
LLVM-emission tripwire: lowerAssignment's .field_access target-type
path used getStructFields, which returns nothing for a union, so the
literal never received its target type.
Unify the lvalue field matcher into a pure fieldLvalueResolve consumed
by both fieldLvaluePtr (GEP builder) and the target-type path, so the
store slot and the RHS target type can't diverge (covers union direct +
promoted members, tuple/vector lanes, and structs).
Resolves issue 0133 (depended on 0135). Regression test: examples/0184.
Notes the now end-to-end union path in issue 0132.
Erasing a single comptime-pack element to a protocol value
(`xx sources[0]` with a protocol target) tripped the pack-as-value
error: buildProtocolErasure treated the index_expr as an lvalue and
took its address via lowerExprAsPtr, whose .index_expr arm lowers the
bare pack as a value (a pack is comptime-only with no runtime storage).
isLvalueExpr now reports a comptime pack index as an rvalue, decided
via the same packArgNodeAt predicate the value path uses — so the value
and lvalue paths can't diverge on what counts as a pack element — and
erasure heap-copies the already-materialized element instead.
Resolves issue 0135. Regression tests: examples/0547, 0548.
`registerProtocolDecl` resolved each method's param/return type NAME
through the flat, visibility-unaware `type_bridge.resolveAstType`, so a
type name colliding across modules bound to the wrong author. In the
repro the user's `Event` enum collides with the stdlib `event.Event`
struct (pulled in by `modules/std.sx`): the protocol grabbed the stdlib
struct, typed an inferred `g_plat.one_event()` as a fieldless struct,
bound the `case .key_up:(e)` payload to `.unresolved`, and emitted
"enum literal '.escape' has no destination type to resolve against".
Resolve both param and return types through
`resolveTypeInSource(pd.source_file, …)` — the visibility-aware resolver
pinned to the protocol's own declaring module, keeping the `Self → *void`
short-circuit. Brings the non-parameterized path to parity with
`instantiateParamProtocol` and concrete-fn signatures. No silent default:
not-visible / ambiguous names still diagnose and poison with `.unresolved`.
Closes issue 0132 — the protocol-return case left open by f13f4ab (which
fixed the enum/union/inline/error-set registration class). Regression
test: examples/0417-protocols-protocol-return-name-collision.sx.
Enum payloads, union fields, inline struct/enum/union field types, and
named error-set references now resolve through the visibility-aware
`inner` recursion hook (the same seam `resolveCompound` uses) instead of
the flat `findByName`. A bare type name in any of these positions now
selects the querying module's OWN author over a same-name namespaced
import -- the own-wins rule already applied to top-level named references
and struct fields.
- buildEnumInfo / buildUnionInfo / resolveInlineEnum / resolveInlineStruct
/ resolveInlineUnion / resolveErrorType take the `inner: anytype` seam;
registerEnumDecl / registerUnionDecl and the struct-const annotation
pass `self` (visibility-aware); resolveAstType passes the stateless `si`.
- resolveTypeWithBindings routes inline type decls and named error refs
through `self` instead of delegating to flat resolveAstType.
Regression tests: examples/0781 (top-level enum payload over a namespaced
import), examples/0784 (inline struct field). Addresses issue 0132's
broader latent class; the protocol-return case (0132 primary) is a
separate registerProtocolDecl fix and stays open. The error-set reference
path is in place but dormant pending error-set per-decl nominal identity
(issue 0134).
`zig build test` now runs the full examples/ + issues/ regression corpus
alongside the Zig unit tests, driven by a pure-Zig test
(src/corpus_run.test.zig) — no shell script in the build path. It spawns
the installed `sx` per example (subprocess-isolated, per-run timeout),
diffs stdout/stderr/exit and optional `sx ir` snapshots, and fails the
build on any mismatch. The file list is enumerated at runtime, so new
examples are covered with no test edit.
- `sx ir` / `ir-dump` now write to stdout (fd 1) instead of stderr, so
the dumps can be piped/redirected.
- `zig build test -Dupdate-goldens` regenerates snapshots in-build,
byte-identical to the legacy `run_examples.sh --update`; on mismatch
the runner prints how to regenerate.
- run_examples.sh kept (still used by tools/verify-step.sh) and made
portable to a bare macOS: timeout/gtimeout fallback, bash 3.2-safe
empty-array handling.
- CLAUDE.md: document the new workflow.
emitProtocolDispatch now requires the user-arg count to equal the
protocol method's parameter list — exact, since protocol signatures
have no defaults, packs, or variadics — and emits the same
"expects N arguments, but M were given" diagnostic plain calls get.
Previously extra args were silently dropped (and missing args left the
thunk reading garbage). The dispatch gains the call-site span for the
diagnostic. examples/1634 pins the rejection; full sweep confirms no
existing code relied on the leniency.
cstring is ONE pointer to a null-terminated u8 buffer, C's char*: thin
(8 bytes, no length; cstring_len walks to the terminator), crossing
#foreign boundaries verbatim in both directions, with ?cstring as the
nullable case lowering to the same bare pointer (null = absent).
Conversion discipline mirrors Odin: a string LITERAL coerces implicitly
(its bytes are terminated constants); any other string is rejected with
a diagnostic naming to_cstring (it may be an unterminated view); and
cstring never coerces to string implicitly — from_cstring(c) is the
explicit zero-copy view, pricing the strlen.
Plumbing: TypeId/TypeInfo builtin slot 18 (first_user 19), name
classifiers, size/align/name tables, LLVM ptr lowering, the ?T pointer
niche, the xx pointer ladder, the literal-gated coercion plan
(isConstString + data_ptr), and the reserved-spelling set. std gains
cstring_len/from_cstring/to_cstring (fmt.sx, re-exported); the old
cstring(size) allocator helper is renamed alloc_string everywhere;
getenv migrates to (name: cstring) -> ?cstring as the canonical user
and env() drops its manual strlen/memcpy.
Pinned: examples/1222 (FFI both directions, literal coercion,
?cstring null paths, round trip) and examples/1173 (both coercion
diagnostics); FAIL pre-feature. The alloc_string rename + getenv
signature shift the .ir snapshots — regenerated. zig build test
426/426; run_examples 604/604.
Spec: reserved spelling + cstring section + C-interop rows.
Two genuine defects behind the 0128 filing (whose original repros were
both poisoned by binding getenv, which std already declares -> *u8):
1. Re-declaring a C symbol was silent first-wins: every call through
the later declaration was typed by the older signature. Foreign
registration now dedupes — equal signatures share one FuncId,
conflicting ones are diagnosed.
2. Foreign -> string / -> ?string returns read garbage: C returns one
char*, but the LLVM signature declared the fat {ptr,i64} (len =
register garbage), and ?string was mis-declared SRET (the hidden
out-pointer landed in the callee's first arg register). cstrRetKind
now classifies such returns, declares them as plain ptr (never
sret), and the call site synthesizes {ptr, strlen} via a
branch-guarded strlen (NULL -> {null,0} / optional null), wrapping
{string, i1} for ?string.
?[:0]u8 itself resolves fine (it is ?string); the spelling works in
return, param, local, and alias positions.
Regression: examples/1221 (plain + optional non-null + NULL paths) and
examples/1172 (conflict diagnostic); both FAIL pre-fix. The extern
dedupe collapses duplicate libc decls, so affected .ir snapshots were
regenerated. zig build test 426/426; run_examples 602/602;
distribution suite 21/21.
The unary .not arm emitted bool_not (LLVM bitwise Not) for every
operand. Correct on i1; on an error binding — an error-set value, u32
tag at the LLVM level — a bitwise not of a nonzero tag stays nonzero,
so 'if !e' held even on a SET error and its branch read the
uninitialized success value (real segfault in the distribution repo's
sqlite tests). Plain integers had the same hole ('!7' was '~7').
Now: bool keeps bool_not; integers and error-set operands lower as the
truthiness complement (cmp_eq against a typed zero); anything else is
diagnosed instead of silently bit-flipped.
Regression: examples/1057 (set error: !e must not hold; success: !e
holds with a real value; integer truthiness) + examples/1171 (!"text"
diagnosed); both FAIL pre-fix. zig build test 426/426;
tests/run_examples.sh 600/600.
lowerEnumLiteral resolved the variant against the raw destination type,
so any non-enum destination fell into resolveVariantValue's silent
return-0 tail with the enum_init stamped as the wrong type:
- ?E destinations produced variant 0 mis-typed as the optional
(observed as variant 0 OR null, layout-dependent);
- builtin destinations (i64) silently became 0;
- unknown variants of real enums silently became variant 0;
- a destination-less literal panicked LLVM emission (unresolved
type reached codegen).
Now: optional destinations unwrap to the child enum (the coercion
layer's .optional_wrap handles E -> ?E), and the remaining shapes are
diagnosed — unknown variant (with the variant list, via the new
emitBadEnumVariant twin of emitBadVariant), non-enum destination, and
no destination (cascade-guarded: silent when the destination's type
already failed to resolve and was reported).
Regression tests: examples/0183 (return/assign/reassign into ?Enum,
non-zero variants, null path) + examples/1169/1170 (each diagnostic);
all three FAIL on pre-fix master. zig build test 426/426;
tests/run_examples.sh 598/598.
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.
Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).
Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.
zig build test: 426/426; examples suite: 595/595.
The plan producer's namespace-fn arms returned the declared return type
without checking type_params, so a qualified generic call's result
carried the unbound T stub: print boxed it as 'T{}', and a non-s64
binding failed LLVM verification (pack monomorphized for the stub,
call returning double). Both fn_ast_map-backed arms now classify
generic callees as generic_fn and infer the return through
inferGenericReturnType, mirroring the bare-identifier path.
extractTypeParam's slice arm only extracted from slice-typed args, so
first(a) with a : [3]s64 at first :: (xs: []$T) -> T left T unbound
and the mono body reached LLVM emission carrying the .unresolved
sentinel (panic). The arm now also extracts from array args via the
array's element type — mirroring the array→slice promotion concrete
slice params already perform; the existing arg coercion handles the
rest.
lowerGenericCall additionally diagnoses any still-uninferrable TYPE
param at the call site instead of monomorphizing unbound — the
deliberate string-at-[]$T gap used to hit the same sentinel panic and
now errors with a source-located message. Comptime value params
($N: u32) and ..$Ts packs bind through their own dispatch and stay
exempt.
Regressions: examples/0212-generics-array-arg-slice-param.sx (scalar /
u8 / struct elements + the slice spelling) and
examples/1168-diagnostics-generic-param-uninferrable.sx (string arg
diagnostic) — both failed pre-fix.
Two lowering sites materialized a local array as a whole LLVM value;
the legalizer scalarizes each such op into one SelectionDAG node per
element, and at ~64K elements the DAG combiner segfaults
(DAGCombiner::visitMERGE_VALUES → ReplaceAllUsesWith).
- lowerVarDecl: an array-typed `---` initializer emits NO store — the
slot stays uninitialized instead of receiving a whole-array undef
store. The tuple zero-init carve-out stays; non-array `---` keeps
the undef store. The interp is unchanged either way (slots start
.undef).
- lowerIndexExpr: element reads on an array with addressable storage
GEP the storage and load one element — the general-expression
sibling of 0110's lowerFor fix — without value-lowering the object
(a dead whole-array load would still reach the DAG). Storage-less
arrays keep the index_get fallback.
Sibling shape filed as 0125: any_to_string's per-array-type arms still
pass the array by value, so a 64K+ array type + any {} print crashes.
Regression: examples/0055-basic-large-stack-array.sx (sx build
segfaulted pre-fix). 22 .ir snapshots re-pinned: removed undef stores
and ig.tmp spills, in-place gep+load (instruction-shape-only churn,
reviewed).
The two not-yet-lowered fn_ast_map paths in resolveCallParamTypes (the
qualified `ns.f(...)` call and the plain free-fn call) resolved each
param type in the CALL SITE's visibility context, so a namespaced
import's param type that is bare-visible only in its own module
diagnosed "type 'X' is not visible" at calls whose caller never names
the type bare. Route both through the E4 source pin
(resolveParamTypeInSource), as the method paths already do.
A generic callee's bare T leaves are not nominal names in that module:
astCalleeParamTypes installs the call's inferred $T -> concrete
bindings (the one binding builder) before resolving, or the pin turns
the unbound leaf into "unknown type 'T'" (regressed examples/0129
through math/scalar.sx's clamp).
Regression: examples/0840 (namespaced fn with a module-bare param
type; failed "not visible" pre-pin).
checkCallArity compares the supplied count against the declared params
(min = params without trailing defaults, max = params.len, unbounded
past a variadic) at the five plain dispatch sites in lowerCall — bare
selected-author + lazy, namespace alias-gate + qualified, struct
method, ufcs. Pack / comptime / generic / #compiler / #builtin callees
keep their own dispatch. The method/ufcs sites also gain the
appendDefaultArgs fill the generic-instance leg already had, so
trailing defaults work on dot-calls instead of emitting under-arity
calls. lowerStmt's local fn_decl arm now registers a pointer into the
AST node in fn_ast_map, not a stack temporary that aliased every later
local fn.
convergeClosureShapeSets, checkErrorFlow, and the unknown-type loop ran
under whatever current_source_file the previous phase left behind —
closure-literal annotations resolved (and reject/unknown-type
diagnostics rendered) against an arbitrary module. Latent while std.sx
was a single file (the ambient happened to be the main file); the
re-export facade restructure exposed it. Each walk now pins
setCurrentSourceFile per decl / per fn (body.source_file is already
stamped by resolveImports). Coverage: examples 0129/1047/1049/1052/
1053/1056 against the facade std.sx. Gates: zbt 426/426, suite 588/588.
Renamed fn aliases failed for EVERY kind (the filed pack-only scope was
a same-name confound: same-name re-exports already resolved through the
name-keyed fn_ast_map). scanDecls now follows ident-/ns.X-RHS const
alias chains (aliasedFnDecl; 0120's hop walk extracted as
followAliasChain) and registers the alias name in fn_ast_map
(absent-only), so every dispatch path — early pack/comptime/generic,
plain lazy-lower, plan-side typing — sees the target decl unchanged.
my_print :: s.print; / my_format :: s.format; now work (the std.sx
re-export shape). Regression: examples/0546 (+rich). Gates: zig build
test 0, suite 588/588.
BoxAlias :: Box; / Box :: r.Box; now resolve instantiation, methods,
annotations, and chains through the aliased template, and re-export one
flat-import level as ordinary own decls (the facade shape the std.sx
restructure needs). selectGenericStructHead consults aliasedStructTemplate
(nominal.zig) before the global template map — own-wins/single-flat alias
author, each hop pinned to the alias author's source, ns.X RHS through
namespaceAliasVerdictFrom, depth-capped. resolveTypeCallWithBindings'
silent .unresolved tail (panicked in LLVM emission) now diagnoses
"unknown type". Also aligns the stale pre-existing calls.test.zig UFCS
plan test with the opt-in model (a47ea14). Regression: examples/0211
(+rich/+facade). Gates: zig build test 426/426, suite 587/587.
A struct constant whose every field serializes — literals, enum tags,
nested aggregates, and (new) const EXPRESSIONS over named consts /
const-aggregate leaves ('r = K + 1', 'g = LIT.r', 'b = A[1]') — becomes
an immutable global: one storage, reads load/GEP it, '@LIT' is
addressable, dead-global elimination drops unused ones. constExprValue
gained a fold-through tail (evalConstIntExpr/evalConstFloatExpr,
source-aware), which also enables const-expression ELEMENTS in array
consts.
A const with a NON-serializable field (a call, a runtime read) keeps
inline re-lowering, and that per-use evaluation is now the documented
contract for the class (pinned: 'CALL.r' reads 1 then 2, side effects
run per use; '#run' is the evaluate-once tool).
Examples: 0180 (migrated shapes + @ptr + copy independence),
0181 (the inline-fallback contract). m3te (23/23) + game rebuilt green.
An array const's '.len' and 'K[<const idx>]' element reads, and a
struct const's field ('LIT.r'), fold as compile-time integer leaves —
usable in array dimensions and other constants' initializers. All
source-aware (the SELECTED author's elements, folded in the author's
context with the cyclic-definition frame); a const out-of-range index
diagnoses at fold time, never wraps.
- evalConstIntExpr gains the three ctx hooks (lookupConstAggLen /
lookupConstArrayElem / lookupConstStructField) + an index_expr arm;
all five ctx implementations extended (stateless tiers fold null).
- Array consts dual-register in module_const_map (value = the literal
node) so the folders see elements; bare reads still hit the GLOBAL
arm first, so no double emission.
- Untyped consts whose RHS is a const-aggregate leaf ('L :: K.len',
'E :: K[1]', 'R :: LIT.r') register in a pass 2b AFTER aggregates,
gated on the receiver naming a const aggregate — a namespaced member
('F :: m.PI_ISH') is never mis-typed by the count placeholder.
Examples: 0179 (folds in dims + const exprs), 1163 (OOB diagnostic).
Any assignment / compound-assignment whose target chain is ROOTED at a
constant — a const-flagged global (array consts, #run consts) or a
module value const (struct consts incl.) — diagnoses 'cannot assign
through constant X' at compile time. A struct const's field write used
to compile and bus-error at runtime (issue 0116); scalars misfired
silently. A deref along the chain (p.*) breaks the root — pointer
writes stay the documented escape until the const-ness steps; a local
shadowing the const name stays writable.
Also: typed struct constants ('W : Color : Color.{...}') register —
the shape list skipped struct_literal, leaving the typed form
unresolved while the untyped one worked.
Examples: 1162 (all rejection shapes incl. the 0116 crash repro),
0178 (typed struct const reads + copy independence).
K : [4]s64 : .[...] and the untyped A :: .[1, 2, 3] register as
is_const globals: one storage, reads GEP it, dead-global elimination
drops unused ones, source-aware reads come free via selectGlobalAuthor.
- registerConstArrayGlobal (scanDecls pass 2): typed via the annotation
(array-ness + dimension/count checked), untyped via element-type
unification — all ints s64; ANY float promotes the element type to
f64 with ints converting exactly; bool/string homogeneous; a
non-numeric mix or non-inferable element asks for an annotation.
- constExprValue converts int elements into float destinations exactly
(the int+float promotion rule, element-wise).
- emitGlobals marks is_const globals LLVMSetGlobalConstant — also flips
the comptime-backed #run globals and __sx_default_context to
'constant' (37 pinned IR snapshots regenerated; runtime unchanged).
- Element shapes: nested arrays, struct elements, strings, bools.
Non-constant elements / dim mismatch / mixed types diagnose loudly.
Examples: 0177 (feature matrix incl. @K reads through *[4]s64 — needs
the 0117 fix), 1159/1160/1161 (diagnostics), 0837 repointed to values.
A '*[N]T' receiver in an index expression reached LLVM emission with an
unresolved element type and tripped the panic sentinel — no read or
write spelling worked. ptrToArrayElem on Lowering recognises the shape;
the index READ path GEPs the pointee array through the pointer value
and loads the element; the write / compound-assign / lvalue /
addr-of-element paths and the expression typer resolve the element type
through the same helper (their GEP machinery already handled a pointer
base). Kept out of getElementType so slice paths don't half-accept a
raw pointer base.
Regression: examples/0176 (read, write, compound, element ptr + deref).
With 0115's own-wins globals landed, the remaining tail modules join
std.sx: every '#import "modules/std.sx"' now carries mem/xml/log/fs/
process/socket/json/cli/hash/test as namespaces (trace stays a direct
import).
Enablers in the same change:
- emit: dead-global elimination — a plain-data global no instruction
references is not emitted, so tail modules' data (hash's 64-entry K
table, OS/ARCH/POINTER_SIZE) stays out of binaries that don't use it.
Comptime-backed globals keep their #run evaluation. 37 pinned IR
snapshots regenerated (dead globals dropped + string renumbering from
the larger module).
- 1055/1056 stop pinning the global error-tag ordinal (it shifts with
program composition); they assert nonzero + tag identity + name.
- specs/readme/CLAUDE.md tail docs updated.
The globals registry (global_names) was last-wins across modules with no
per-importer gate: any module's bare K could read/write/type against an
unrelated module's same-named global (hash.sx's K table hijacked every
user K once std's namespace tail pulled hash into the program), and an
own const of an unsupported shape borrowed another module's const and
panicked at the unresolved-type tripwire.
- var_decl joins RawDeclRef: module globals are selectable raw authors.
- selectGlobalAuthor (the globals analogue of F2's selectModuleConst):
own author wins, one flat-visible author resolves, >=2 distinct flat
authors diagnose loudly, authored-but-not-visible diagnoses, and a
compiler-synthesized global (no raw author) emits untracked. A var_decl
author whose per-source registration was deduped at flat-merge (two
modules declaring the same extern symbol) serves the symbol's
registration.
- All bare-identifier global sites route through it: value read, addr-of,
assignment (store + compound), lvalue address, fn-ptr call, call param
typing, and expression type inference.
- selectModuleConst gains .own_opaque: an own const author with no
materialized per-source value (e.g. an array '::' const) blocks
borrowing another module's same-named const — the read diagnoses
cleanly instead of panicking.
- The fn-as-VALUE arm admits raw-facts-only authors: an own fn whose name
a flat-merge collision dropped from the global decl list (first-wins)
now resolves via author selection for func_ref/closure/Any shapes too.
Regressions: examples 0835 (own const vs flat array global), 0836 (main
const vs namespaced array global, incl. inference), 0837 (own array
const never borrows cross-module — clean unresolved).
The lowerCall namespace branch routed alias.fn() through the global
qualified registration (first-wins) at any import depth, and through the
global last-wins bare map for comptime/generic members. Plain-identifier
alias roots now resolve via the carry-aware namespaceAliasVerdict:
- visible alias (own edge or ONE flat hop): the member dispatches the
TARGET module's own fn (namespaceFnMember + fd-keyed bareAuthorFuncId),
so two modules' same-named aliases each call their own target.
- two direct flat imports carrying the alias to distinct targets:
loud ambiguity diagnostic.
- alias only reachable beyond one hop: "namespace 'X' is not visible".
- foreign / builtin / #compiler members keep the literal-symbol path.
Regressions: examples 0832 (two-hop), 0833 (carried collision),
0834 (own-target pin / first-wins repair).
- objc.sx, objc_block.sx (from std/) + sdl3/opengl/raylib/stb/stb_truetype/
wasm vendor bindings (from modules/ root) -> modules/ffi/
- std/uikit.sx deleted: platform/uikit.sx already declares UIApplicationMain
and imports objc; '#framework "UIKit"' cannot live in a file imported on
macOS targets (unconditional link directive, UIKit is iOS-only), so the
three iOS-only examples carry the 3-line glue inline. 1607/1608/1616 also
un-rotted (dead ns_string -> 'xx "..."' Into conversions, callconv(.c)
msgSend fn-ptrs) — all three build for ios-sim/ios again.
- math/math.sx -> math/scalar.sx; one spelling '#import "modules/math"'
everywhere (4 pinned IR snapshots regenerated: dir import adds Vec2/Mat4
to the type tables).
- compiler.sx -> build.sx (imports, CLAUDE.md bundling table, specs.md).
- testpkg/ + test_c.sx -> tests/fixtures/ (resolve CWD-relative from repo
root, same as vendors/).
- library-internal imports use full modules/... paths (std.sx tail,
platform/bundle.sx, fixtures).
Two coupled capabilities on the road to the std restructure
(current/PLAN-STDLIB.md, issue 0114):
1. alias.Type.method() / alias.Type as a call head, alias.CONST, and
alias.Enum.variant now resolve — previously only alias.fn() and
type-position alias.Type worked. objectIsValue treats an
alias-rooted field_access as a type head; the call path strips the
alias to the existing Type.method machinery; lowerFieldAccess
resolves alias.CONST pinned to the target module and alias.Enum.x
as a typed enum literal; resolveTypeWithBindings resolves qualified
type_exprs pinned to the target.
2. The carry rule: namespaceAliasTarget resolves an alias from the
file's own edges first, then from DIRECT flat imports (one level),
diagnosing two distinct carried targets as ambiguous. All qualified
shapes work through a carried alias — the std.sx namespace tail
(mem.GPA.init() etc.) is now expressible.
Regression: examples/0831-modules-namespace-alias-carry.sx (direct +
carried, all seven shapes).
try foo() catch (e) { } // legal
try foo() catch e { } // parse error with a migration hint
Same capture style as the for-loop. All four catch shapes keep working
with the parenthesized binding — block, bare-expression body, and the
== match sugar — and the no-binding forms are unchanged. onfail follows
the same rule (onfail (e) { }); its expression-cleanup form is
disambiguated by the paren-group-before-brace lookahead, so
onfail (f()); stays an expression cleanup.
AST unchanged; the printer renders the parens; the #run escape help
text updated. Corpus migrated (57 catch + 3 onfail bindings, in-source
parser test strings, specs incl. grammar rules, readme untouched —
no catch examples there).
Regression: examples/1157-diagnostics-catch-binding-needs-parens.sx;
re-captured stderr for 1010/1013/1037/1123 (migrated source echoed in
carets + help text).
globalInitValue had no unary_op arm, so g : s64 = -1; fell into the
catch-all 'must be initialized by a compile-time constant' even though
constExprValue already folds negate(literal) for the module-const
identifier route. The new arm routes through constExprValue and applies
the direct-literal rules to the folded value: checkIntLiteralFits on
ints (g : s8 = -300 gets the range diagnostic), and a negated float at
an integer global narrows only when integral (-4.0 folds to -4, -4.5
errors). Binary-op initializers keep the specific non-constant
diagnostic.
Regression: examples/0175-types-negative-literal-global.sx.
checkIntLiteralFits range-checks a literal against its integer target
(builtins + custom widths via intLiteralRange; width-64 types skip —
every representable literal is a legal bit pattern there) and diagnoses
with the type's range and an xx/cast hint. Wired into the .int_literal
arm (covers decls, assignments, call args, struct-literal fields),
lowerStructConstant, and globalInitValue.
A negated literal now folds to a single constant so -128 range-checks
as -128 rather than as an out-of-range +128 intermediate. An explicit
xx operand skips the check — truncation stays available on request
(cast(T) was already exempt: its value arg lowers without the target).
examples/0300-closures-lambda.sx pinned 133 wrapping to -3 through an
s3 param — the exact class this outlaws; updated to a fitting value.
Found during the fix and filed separately: issue 0113 (negated-literal
global initializers rejected as non-constant; pre-existing).
Regressions: examples/1156-diagnostics-int-literal-out-of-range.sx,
examples/0174-types-int-literal-boundaries.sx.
xs[1..=3] (end inclusive), xs[0<..<4] (both exclusive), xs[..=2]
(prefix form with markers, implicit 0 start), xs[2<..] (open end,
exclusive start), and xs[..] (whole collection) — lowered as lo+1 /
hi+1 on the existing subslice op. Strings slice through the same path.
An explicit end marker requires an end expression, matching the
for-header rule.
Regression: examples/0052-basic-slice-range-bounds.sx.
Each side of '..' takes an optional bound marker, defaulting to
start-inclusive, end-exclusive (a..b == a=..<b; a..=b stays the short
end-inclusive spelling):
for 0<..<N (i) { } // 1 .. N-1 (both exclusive)
for 0=..=N (i) { } // 0 .. N (both inclusive)
for 0<..=N (i) { } // 1 .. N
for 0..<N (i) { } // 0 .. N-1 (explicit default)
for xs, 2<.. (x, i) // open range, exclusive start: i = 3, 4, ...
The nine lexemes are single tokens (maximal munch on '<'/'='/'..'), so
expression parsing never sees the leading marker as a comparison; '<',
'<<', '<=', '==', '=>' lex unchanged. An explicit end marker makes the
end expression mandatory; open forms are a.. / a<.. / a=... Works in
runtime, multi-iterable, and inline-for headers.
Regression: examples/0051-basic-for-range-bounds.sx (full matrix, open
start-marked ranges, comptime unroll, runtime bounds, lexer
non-regression); 1152's pinned message generalized.
The for header is now a comma-separated list of iterables with a
positional capture group and no ':' separator:
for xs (x) { } // collection
for 0..n (i) { } // range (end exclusive)
for 1..=5 (a) { } // ..= inclusive end
for xs, 0.. (x, i) { } // index idiom (replaces (x, i))
for xs, ys (x, y) { } // parallel (zip) iteration
for xs (x) => sum += x; // arrow body (full statement)
First-iterable-wins: the first iterable's length drives the loop and
must be bounded; the other positions follow by their own cursors (a
non-first range's end is not consulted or evaluated; a shorter
non-first collection is read past its length on mismatch). The old
single-iterable index capture is replaced by the trailing open range.
Capture/call disambiguation is positional: the paren group immediately
before '{' or '=>' is the capture, every earlier top-level group is a
call. 'for zip(a, b) (x, y)' calls zip; 'for f(n) { }' reads (n) as
the capture and errors with a parenthesize/add-capture hint. The old
':' form errors with a migration hint.
Lowering is unified across forms: one cursor slot per position (ranges
start at their start, collections at 0), all advanced together, the
first position's bound terminating. inline for keeps the single
bounded comptime range.
Migrated the full corpus (examples, library modules, issue repros,
in-source test strings). New coverage: examples/0050 (the full feature
surface) and examples/1149-1155 (seven diagnostic faces). specs.md For
Loop section + grammar rewritten; readme teaser updated.
lowerBreak/lowerContinue emitted a bare br, and the enclosing block's
emitBlockDefers — seeing the terminator — discarded the pending entries
on the assumption a return had already drained them. The breaking
iteration's defers were silently skipped, leaking whatever the cleanup
released.
Lowering.loop_defer_base records the defer-stack height at each loop's
body start (while / for / range-for, saved and restored alongside
break_target); break/continue drain non-onfail entries down to it in
LIFO order via the non-truncating emitLoopExitDefers before branching.
Truncation stays with the lexical block exits — the same entries still
belong to the fall-through path after the branch containing the break.
break/continue outside a loop now diagnose instead of no-op'ing.
Regression: examples/0049-basic-defer-break-continue.sx (for and while,
break and continue, nested-block LIFO drain).
lowerFor's by-value element fetch emitted index_get on the array VALUE;
the emitter realizes that as a whole-array spill to a stack temp + GEP,
per iteration — O(N^2) bytes copied per loop (and pre-0109 it also grew
the stack per iteration, segfaulting a [4096]s64 loop).
When the iterable is an array with addressable storage (and not deref'd
from a pointer, whose identifier alloca holds the pointer rather than
the array), the fetch is now index_gep on the storage + one element
load. Storage-less arrays keep the index_get fallback. The loaded
element remains a copy — mutating the capture does not write back.
Regression: examples/0048-basic-for-array-large.sx (sum over 4096
elements + by-value copy-guard).