Part B begins: `#foreign` becomes an alias for `extern`. First of the four
`#foreign` parser paths to migrate — the data-global form
(`name : T #foreign [lib] ["csym"];`). It now builds the SAME extern-named
VarDecl (`is_extern`/`extern_lib`/`extern_name`) that the postfix `extern`
global path already produces, instead of `is_foreign`/`foreign_lib`/`foreign_name`.
Behavior-preserving: lowering coalesces the two forms identically — the symbol
name is `extern_name orelse foreign_name orelse name` (decl.zig:1119), and both
`is_foreign` and `is_extern` feed the same `.is_extern` IR flag + early-return
(decl.zig:1127,1141). The A->B gate already proved fn/global/class lower to
byte-identical IR, so the corpus locks this with zero snapshot churn.
Suite green: 10/10 steps, 444/444 unit, 643 corpus, 0 failed.
The fn-decl, const-with-type, and runtime-class `#foreign` paths still build the
legacy AST; they migrate next (the fn path needs the deferred visibility-gate +
variadic alignment first).
Eliminates the recurring -Dupdate-goldens churn: these 5 were 0-byte
outliers while 484 other empty goldens use the writeGolden-produced
1-byte "\n" form. The corpus runner trims trailing newlines on both
sides during verify, so both forms passed — but regen always rewrote
them to 1-byte. Conforming them makes -Dupdate-goldens idempotent.
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.
example/1227 exposes the sx fn `sx_triple` to C under the symbol `triple_c`
via `export "triple_c"`; the companion C calls `triple_c` by that name.
RED: the define path emits the fn under its sx name (`sx_triple`) and
ignores the parsed `extern_name`, so the C reference to `triple_c` is
undefined at AOT link. The next commit consumes the rename on the define
path (gap iii) and greens it.
`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.
Phase 2 of the extern/export stream verifies `export` (define + expose a
C-ABI sx symbol) end-to-end. C->sx-by-name linkage cannot work under the
corpus's `sx run` JIT mode — a JIT-resident symbol is invisible to a
dlopen'd C dylib's flat-namespace lookup — so this lands a new AOT
execution mode for the corpus: an `expected/<name>.aot` marker switches an
example from JIT `sx run` to a `sx build` + execute flow, linking the sx
object with its C `#source` companions into a native binary.
example/1226 defines `sx_square :: (n: i32) -> i32 export { ... }` and a
companion .c that declares `extern int sx_square(int)` and calls it back.
RED: with `export` not yet lowered, the AOT link fails with an undefined
`_sx_square` (the define path still emits it `internal` + with an implicit
ctx slot, and lazy lowering leaves an uncalled export fn as a bodiless
declare). Phase 2.1 greens it.
Also retires the standalone `tests/run_examples.sh` runner — `zig build
test` (src/corpus_run.test.zig) is now the sole corpus runner, and the
shell mirror would have needed its own AOT-mode port to stay in lockstep.
verify-step.sh drops its redundant step (zig build test already runs the
corpus); CLAUDE.md documents the `.aot` mode.
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.
Add examples/1225-ffi-extern-global.sx — '__stdinp : *void extern;'
references libSystem's stdin pointer via the bare 'extern' modifier on
a typed var decl (the extern-named counterpart of the #foreign global
in examples/1205). Hand-authored snapshot expects the success output.
RED: 1225 is the sole corpus failure (636 ran, 1 failed) — parse error,
'extern' after a type annotation is not yet accepted in the var-decl
path. Phase 1.2d parses it and lowers the extern global.
xfail commit per the cadence rule.
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.
Add examples/1224-ffi-extern-fn-rename.sx — 'c_abs :: (n) -> i32
extern "abs";' binds C's abs via the optional symbol-name override.
Hand-authored expected captures the success output (c_abs(-42) = 42).
RED: 1224 is the sole corpus failure (635 ran, 1 failed) — parse error,
the '"abs"' string after 'extern' is not yet accepted. Phase 1.2b
parses the optional [LIB] ["csym"] tail and consumes the rename.
xfail commit per the cadence rule.
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).
Add examples/1223-ffi-extern-fn.sx — binds libc 'abs' via bare 'extern'
(sx name = C symbol, no rename). Hand-authored expected/ captures the
SUCCESS output (abs(-7)=7 / abs(42)=42, exit 0).
RED: 1223 is the sole corpus failure (634 ran, 1 failed) — it parses
then errors at sema ('body produces no value') because lowering does
not yet route extern fns through declareExtern. Phase 1.1 wires the
lowering and turns this green.
xfail commit per the cadence rule (no commit both adds a test and makes
it pass).
parseFnDecl now calls parseOptionalExternExport() after the callconv
slot and stores the modifier on FnDecl.extern_export. For 'extern' the
body is ';' (an empty-block placeholder — the modifier carries the
linkage, no *_expr node, per the naming constraint). Both fn-decl
lookahead predicates (isFunctionDef, hasFnBodyAfterArrow) now treat
kw_extern/kw_export as fn-body markers beside kw_callconv, so
'(...) -> R extern;' is recognized as a fn def rather than a fn-type
const.
Per user feedback, decision 4 ("library separate") is REVISED: extern
carries an optional LIB + "csym" axis mirroring '#foreign LIB "csym"',
so it is a true #foreign superset (Gate A->B requirement — the Part B
migration of 466 #foreign uses across 6 libs must preserve each
symbol's library). Added FnDecl.extern_lib/extern_name and
VarDecl.extern_lib (beside is_extern/extern_name).
All unconsumed by lowering: extern parses, but a fn still errors at
sema (body produces no value). Suite green (443 unit / 633 corpus).
lock commit.
Add ast.ExternExportModifier { none, extern_, export_ } beside
CallingConvention; FnDecl.extern_export and VarDecl.is_extern/extern_name
fields (all defaulting to absent); and Parser.parseOptionalExternExport()
mirroring parseOptionalCallConv.
None of this is consumed by a decl path yet — no user-facing behavior
change, corpus diff empty. Two inline parser unit tests pin the helper's
keyword mapping and the field defaults. Phase 1.0 wires the helper into
the fn-decl path. lock commit.
Lex 'extern' and 'export' as keywords beside 'callconv': new token.Tag
variants + keywords StaticStringMap entries + LSP semantic-token keyword
classification. Adds a 'lex linkage keywords' unit test.
Tokens only — parser/AST plumbing and lowering land in later phases.
Corpus sweep confirmed no .sx identifier collides with the new reserved
words. lock commit per the cadence rule.
Two new workstreams:
- ASM: inline assembly — asm { "tmpl", "=r" -> T, "r" = expr, clobbers(.…) },
multi-return tuples; lowers via the existing llvm_api.c (no shim).
- FFI-linkage: add extern/export postfix keywords, migrate every #foreign onto
them, then purge 'foreign' from the tree (end-state invariant).
Drop current/ from .gitignore so plans + checkpoints are tracked normally
(the dir was ignored; only checkpoints had been force-added). Includes
docs/inline-asm-design.md. specs.md change left uncommitted.
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.
- 0132: rewrite to the verified root cause -- protocol method signature
registration resolves type names via flat findByName and picks the wrong
same-name author. Original payload-field hypothesis kept as superseded;
repro switched to canonical `impl ... for` syntax. Still open (the
protocol path is unchanged).
- 0133: assigning a struct literal to a union member panics ("unresolved
type reached LLVM emission"); pre-existing, surfaced while testing.
- 0134: a same-name `error` set collapses into a namespaced import's set --
error-set declarations lack per-decl nominal identity (E6a gap); this is
what keeps the 0132-class error-ref resolution dormant.
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.
THREADSAFE=0 was correct when sx had no threads; with std.thread (S6)
and std.http's pooled dispatch (S7b), concurrent connections corrupted
sqlite's unprotected globals (caught live: distd under ab -c20 died
with free-of-unallocated inside yy_reduce). Serialized mode is
sqlite's own default and safe for every consumer; per-connection use
across threads is the supported pattern.
thread_pool_count = 0 (default) keeps handlers inline on the loop
thread — the measured fast path (BENCH-HTTPZ.md). N > 0 dispatches
each parsed request to a std.thread Pool of N workers, completing the
httpz two-pool shape: the connection freezes as CONN_HANDLING (no
reads, growth, eviction, or recycling — the worker borrows views into
its read buffer), the worker runs the handler under a per-job arena
and serializes into job-owned bytes, the completion queues under the
PoolState mutex, and the loop wakes through the new std.event wake
channel (kqueue EVFILT_USER + EV_CLEAR; the epoll twin maps to
eventfd), attaches the response, compacts the buffer, and resumes
keep-alive/pipeline handling. A full backlog sheds with 503. Stale
completions (generation mismatch after close) are dropped. Pool mode
requires the server's constructing allocator to be thread-safe
(GPA/malloc), documented on the knob.
PoolState lives behind a heap pointer (it embeds a Mutex and is shared
with workers; the Server struct itself is returned by value).
serialize_response/run_handler_job share one serialize_bytes.
examples/1633 gains the pooled section (GET, body echo, 404 across
worker threads) plus the loop-wake path exercised end to end; AOT run
five times. examples/1632 unchanged but the Event struct gains `user`.
pthread bindings with darwin opaque sizes (mutex 64B, cond 48B; glibc
divergence is a C3 per-OS item). Mutex/Cond initialize IN PLACE and
Pool lives behind Pool.create's heap pointer — POSIX sync objects are
address-sensitive, so nothing here moves after setup. Thread.spawn
takes the C2 re-entry contract entry (callconv(.c), fabricates its own
Context); Pool workers do exactly that with a per-worker malloc-backed
GPA, then run default-conv tasks inside it. submit returns false on a
full backlog (httpz thread_pool backpressure); shutdown finishes
queued work and joins every worker.
examples/1637 pins: 4 raw threads x 1000 locked increments, 100 pool
tasks summing exactly once across 4 workers, a held worker + full
backlog refusing the next submit, clean shutdown. JIT + AOT (AOT run
three times). The std.sx barrel carries thread; .ir snapshot regen is
the usual renumbering.
Both halves of the C2 contract already work in JIT and AOT; these
examples pin them. 1635: libc qsort drives an sx callconv(.c)
comparator passed by name as a typed fn-pointer param. 1636: a real
pthread enters sx through a callconv(.c) entry, fabricates its own
Context (push Context with a local GPA), and runs default-conv sx code
that allocates through it — the re-entry contract std.thread (S6)
stands on. Also unblocks the sqlite callback APIs (hooks/UDFs) left
unbound by design in P5.1.
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.
The protocol declares dealloc_bytes(ptr) — the size argument I passed
at three sites was silently accepted and dropped by the compiler
(issue 0131); these calls would stop compiling the moment that
diagnostic gap is fixed.
No conjured GPA: the arena chunks come from own_alloc (captured at
Server.init), so all server memory flows from the allocator the app
constructed it with — the point of the implicit context model.
Handler and serialization allocations through the implicit context die
with the request; response bytes survive via the own_alloc copy made
inside the push scope. Without this every request leaked its render
concats into the loop's long-lived context.
read_buf_cap is now the per-request LIMIT, not a preallocation: slots
start at 16K, double when full (one-step sizing when a Content-Length
declares the body), and keep their grown capacity for slot reuse. At
the limit the refusal distinguishes oversized headers (431) from an
oversized body (413). Unblocks A1: distd accepts multi-hundred-MB
artifact uploads — preallocating that per slot was never an option.
examples/1633 adds a body past the initial capacity echoing intact.