Compare commits

...

139 Commits

Author SHA1 Message Date
agra
ded106333b docs(design): execution-model roadmap + reify implementation stream
Add the async-first execution-model roadmap (comptime JIT spine, colorblind
fibers/Io, atomics, hot-reload) with all seven decisions resolved and
three-way reviewed, and carve the first stream: comptime type_info/reify
(PLAN-REIFY + checkpoint) — the codebase-validated foundation for channel
result types and race's synthesized tagged union.
2026-06-16 16:43:29 +03:00
agra
b6a7378af4 feat(dist): bundled-zig link backend for hermetic macOS/Linux/Windows builds
Drive a bundled `zig` as `zig cc` for the AOT link step, supplying lld + CRT
+ libc (musl/glibc/mingw) so `sx build` produces native binaries with no host
toolchain. Default Linux output is static musl (portable-anywhere).

- src/zig_backend.zig: discover zig ($SX_ZIG / bundled-next-to-exe / PATH);
  bundled-vs-PATH provenance gates auto-activation.
- src/target.zig: selectZigLinker + emitZigLinkArgv + zigTargetTriple, dispatched
  before the per-OS branches; macOS/Linux/Windows in scope.
- src/ir/emit_llvm.zig: LLVMNormalizeTargetTriple so vendor-less zig triples
  (e.g. x86_64-windows-gnu) parse to the correct OS/object format (COFF not ELF).
- src/main.zig: --self-contained / --no-self-contained; linux-musl, linux-musl-arm,
  windows-gnu shorthands; de-vendor linux/linux-arm to match the corpus runner.
- examples/1660: Windows Win32 print-42 + exit(0) via kernel32 (ir-only off-Windows).

Auto-activates only for a bundled zig; a PATH-only zig engages under
--self-contained, so native dev/CI builds are never silently rerouted.

Docs: readme Cross-Compilation, design/bundled-zig-link-backend-design.md, current/PLAN-DIST.md.
2026-06-16 15:56:06 +03:00
agra
0e0ee40528 docs(asm): symbol refs are portable — explain the auto-:c mechanism
Updates the symbol-operand guide: x86 now uses the same plain %[fn] as
aarch64, and a 'How the portability works' note explains the mechanism
(compiler auto-injects LLVM's :c modifier for "s" operands, equivalent
to GCC :P/%P0 for x86 calls, no-op on aarch64, overridable). Drops the
stale per-arch :P guidance; checkpoint updated.
2026-06-16 09:05:15 +03:00
agra
066ba54346 feat(asm): portable symbol refs — auto-inject :c operand modifier
A `%[name]` that references a symbol ("s") operand without an explicit
modifier now lowers to `${N:c}` (LLVM 'bare constant — no punctuation')
instead of `${N}`. This makes `bl %[fn]` / `call %[fn]` portable across
targets with no per-arch knowledge: x86 would otherwise render `$cb`
(an invalid call target, requiring a hand-written `:P`); aarch64 is
unaffected. Verified `:c` is equivalent to `:P` for x86-64 calls (both
emit R_X86_64_PLT32), and correct for branch targets, RIP-relative
addressing, and `$`-prefixed absolute immediates.

renderAsmTemplate injects `:c` only for symbol operands lacking an
explicit modifier (asmNamedIsSymbol helper); an explicit `%[name:X]`
still wins (escape hatch). x86 example 1659 drops its `:P` for the same
plain `%[fn]` as aarch64 1656. Snapshots regen to `${N:c}`. zig build
test green (668 corpus, 446 unit).
2026-06-16 09:04:23 +03:00
agra
79042ab9ab docs(asm): note x86 %[fn:P] call modifier + checkpoint x86 coverage 2026-06-16 08:37:09 +03:00
agra
17e3b91eb9 test(asm): x86_64 cross-arch siblings for place + symbol operands
Adds ir-only x86_64 examples mirroring the aarch64 feature examples, so
each emit path is locked on both arches:
- 1657 read-write `+`  → "incq ${0}", "=r,0" (tied input)
- 1658 indirect `=*m`   → "movq $$42, ${0}", "=*m"(ptr elementtype i64)
- 1659 symbol `"s"`     → "call ${2:P}", direct call to an exported sx fn

Each is x86-pinned (ir-only on this aarch64 host — the .ir is the
assertion; runs on x86_64-linux, main returns 0 on success / 1 if the
asm misbehaved). x86 templates validated by cross-emitting an object
(LLVM's integrated assembler accepts them; objdump confirms 1659 is a
direct `call` reloc to cb). Note: x86 direct calls need the `P` operand
modifier (`%[fn:P]`); aarch64 `bl %[fn]` needs none. Pure additive
locks, no compiler change. zig build test green (668 corpus, 446 unit).
2026-06-16 08:36:33 +03:00
agra
a0face7571 docs(asm): document symbol operands ("s") + checkpoint
Adds a 'Symbol inputs — "s" = fn' section to docs/inline-assembly.md
(direct bl/call, portability, the export-vs-callconv linkage point) and
logs the symbol-operand + round-trip work in CHECKPOINT-ASM.
2026-06-16 08:26:22 +03:00
agra
10f4137cbd feat(asm): symbol operands ("s") — direct call/branch to a function
A `"s"` input operand feeds a function/global symbol; the template's
%[name] emits the platform-mangled name, so `bl %[fn]` / `call %[fn]`
branches DIRECTLY to it (PC-relative, no register load — one fewer
indirection than register-indirect `blr`).

Lowering: an `"s"` input lowers its RHS normally (a function name →
`ptr @fn`); the rejection added last commit is removed. Emit: a symbol
operand is passed with its OWN llvm type (LLVMTypeOf) and no coercion —
the function value is a `ptr`, and the old coerce-to-register-int path
mistyped it and failed the verifier. New asmIsSymbol helper.

Verified on aarch64: examples/1656 (sx → asm → bl _cb → sx → 42); the
emitted asm is a direct `bl <_cb>` (objdump-confirmed), IR constraint
`...,s,...`(ptr @cb). Flipped 1656 from the rejection lock to a runnable
aarch64 example. zig build test green (665 corpus, 446 unit).
2026-06-16 08:24:53 +03:00
agra
c187122531 test(asm): reject symbol "s" operands cleanly + lock (symbol-op prep)
A symbol operand (constraint "s") feeds a function/global symbol whose
mangled name the template emits — enabling a DIRECT `bl %[fn]` (one
fewer indirection than register-indirect `blr`). Until now `"s" = fn`
fell through to emit and produced an LLVM-verifier crash (param type
mismatch). Reject it at lowering with a clear diagnostic instead, and
lock that with examples/1656-platform-asm-symbol-operand.sx. The next
commit implements it and flips the example to run (→ 42).
2026-06-16 08:19:18 +03:00
agra
1346a2d020 test(asm): round-trip example — asm calls back into an sx function
Adds examples/1655-platform-asm-callback-into-sx.sx: a global-asm
trampoline (_caller) that `bl _cb` back into an `export`ed sx function.
Demonstrates the sx → asm → sx round trip and that `export` (external
linkage + stable C symbol + C ABI) is what makes the callback symbol
resolvable — `callconv(.c)` alone leaves it internal and it DCE's away.
Runs under the JIT on aarch64-macos (→ 42); ir-only elsewhere. Locks
current behavior; no compiler change.
2026-06-16 07:55:05 +03:00
agra
e7eeecc0f3 docs: move inline-asm design doc to a top-level design/ folder
Moves docs/inline-asm-design.md -> design/inline-asm-design.md (the
internal design record now lives under design/, separate from the
user-facing docs/). Updates all links: current/CHECKPOINT-ASM.md,
current/PLAN-ASM.md, current/PLAN-EXTERN-EXPORT.md (../docs -> ../design)
and docs/inline-assembly.md (same-dir -> ../design).
2026-06-16 07:46:01 +03:00
agra
b4d1ce78c3 docs(asm): add user-facing inline-assembly guide
Adds docs/inline-assembly.md — a how-to guide for inline assembly in the
docs/error-handling.md style: mental model, operands (inputs / value
outputs / naming + auto-naming rule), the result-type table, volatile,
clobbers, all three `-> @place` forms (write-through / read-write /
indirect-memory), multi-instruction `#string` templates, global asm +
lib-less extern, the JIT/AOT-yes vs `#run`-no execution model, a
cookbook (read-register, x86_64 syscall, divmod), and rules of thumb.
All aarch64 snippets are verified to run; x86_64 ones are labeled. The
design doc (docs/inline-asm-design.md) stays as the internal rationale;
this guide is the user-facing companion, linked from readme.md.
2026-06-16 07:41:14 +03:00
agra
73f5f0ed11 docs(asm): checkpoint comptime-call guard (1654) 2026-06-16 07:29:56 +03:00
agra
ab7fc393b6 test(asm): pin loud failure of #run into a module-asm symbol
Adds examples/1654-platform-asm-global-comptime-call.sx — the comptime
guard. A module-asm symbol only exists after assemble+link; the comptime
interpreter resolves extern calls via host dlsym, where it's absent, so
`#run my_add(…)` fails with a clear diagnostic ("comptime extern call:
symbol not found via dlsym") rather than misfiring. Runtime calls work
(1648/1653). dlsym-miss precedes asm assembly, so arch-independent — no
.build. Locks current behavior; no compiler change.
2026-06-16 07:29:38 +03:00
agra
66e1e39418 docs(asm): correct stale 'AOT only' module-asm prose (JIT works)
sx run compiles to an object before ORC relocation, so module asm is
assembled in and its symbols resolve at JIT main execution. Corrected
the Phase F note, Current state, and Next step; the only real boundary
is a compile-time #run into a module-asm symbol (loud dlsym-miss).
2026-06-16 07:25:32 +03:00
agra
e954f044d8 test(asm): global asm runs under the JIT (sx run), not just AOT
Adds examples/1653-platform-asm-global-jit.sx — a module-scope asm { … }
block executed via `sx run` (no `aot`). sx run compiles the module to an
in-memory object (the integrated assembler assembles the `module asm`
into it), then ORC relocates and runs it, so a module-asm symbol IS
resolvable at JIT main execution — the long-assumed "AOT only" limit was
stale. Sibling of 1648 (same feature via AOT). Locks current behavior
(exit 42); no compiler change.
2026-06-16 07:24:09 +03:00
agra
d5aee7a222 docs(asm): checkpoint indirect-memory =*m — inline asm feature-complete 2026-06-16 07:10:31 +03:00
agra
cb6c032c58 feat(asm): indirect-memory =*m place outputs
Implements indirect-memory (`=*m`) `-> @place` outputs — the last
substantive asm feature. Unlike a write-through `=` output (which
returns a value that is then stored), an indirect output passes the
place ADDRESS to the asm and the asm writes through it; there is no
return slot.

emitInlineAsm:
  - indirect outputs are excluded from the LLVM return type;
  - their pointer is passed as an opaque `ptr` call arg, placed FIRST
    (the arg-consuming constraint order is: output-section indirect
    pointers, then inputs, then read-write tied seeds);
  - each indirect arg gets an `elementtype(T)` call-site attribute
    (required in the opaque-pointer era), T = the pointee type;
  - the store-back loop skips indirect outputs (already written).
New asmIsIndirect helper. Lowering stops rejecting `*` (constraint kept
verbatim; `=*m` reaches the constraint string as-is). asmOperandIndex
is unchanged — indirect outputs still count as operands, so `%[name]`
${N} numbering holds.

Verified by running on aarch64: store-through-pointer (str x9, %[out]
→ 42, IR `=*m,~{x9}` with `ptr elementtype(i64)`) and a mixed case
(indirect + value output + input → `=*m,=r,r`, indirect ptr arg first,
${0}/${1}/${2} correct). 1652 flipped from the rejection lock to a
runnable aarch64 example (ir-only elsewhere). zig build test green
(661 corpus, 446 unit).
2026-06-16 07:09:17 +03:00
agra
2a43713d7f test(asm): lock indirect-memory =*m rejection (Phase G prep)
Adds examples/1652-platform-asm-indirect-mem.sx exercising a `=*m -> @x`
indirect-memory place output. Currently rejected loudly at lowering
("not yet implemented"); this locks that behavior as a passing test.
The next commit implements indirect-memory outputs and flips this
example to run end-to-end (store-through-pointer → 42).
2026-06-16 07:05:05 +03:00
agra
59469f2b2f docs(asm): checkpoint x86_64 syscall-write example (1651) 2026-06-16 06:39:14 +03:00
agra
cdd920b692 test(asm): x86_64 Linux syscall-write example (ir-only lock)
Adds examples/1651-platform-asm-x86-syscall-write.sx — the canonical
inline-asm use case: `write(2)` via a raw x86_64 `syscall` (SYS_write
in rax, fd/buf/count pinned to rdi/rsi/rdx, rcx+r11+memory clobbered,
byte count returned in rax). Exercises register-pinned inputs, a pinned
value output, a pointer input (*u8 -> rsi), and clobbers(.…) lowering
together.

x86-pinned via .build { "target": "x86_64-linux" }: ir-only on this
aarch64 host (the .ir snapshot locks the exact constraint string
`={rax},{rax},{rdi},{rsi},{rdx},~{rcx},~{r11},~{memory}` — the §II.11
silent-miscompile risk zone), runs natively on x86_64-linux printing
"ok\n" (hand-authored .stdout, asserted only in execute mode).

Pure additive test coverage — no compiler change (lock commit).
zig build test green (660 corpus, 446 unit).
2026-06-16 06:38:13 +03:00
agra
9e7661b915 docs(asm): checkpoint 0138 resolved — output-to-const rejection done 2026-06-16 06:30:22 +03:00
agra
2a954ceeb6 fix(0138): diagnose @scalar-const address-of (no storage)
A scalar `::` constant folds to its value and has no storage. The
unary `.address_of` lowering (src/ir/lower/expr.zig) skipped the
alloca path (is_alloca == false) and resolveGlobalRef (scalar consts
get no storage global), falling through to the generic addr_of arm,
which reinterpreted the folded value as a pointer:
`inttoptr (i64 <value> to ptr)`. That wild pointer segfaulted on
deref and emitted invalid stores for inline-asm `-> @const`.

Diagnose instead, in the address_of(identifier) path: a non-alloca,
non-ref-capture, non-pack-elem scope binding (local scalar const) and
a module_const_map name not backed by storage (module scalar const)
both report "cannot take the address of constant '<name>' — a scalar
'::' constant has no storage …" and return a placeholder Ref. Chose
diagnose over materializing read-only storage (consistent with the
fold-only scalar model). Array/struct consts keep real storage and
stay addressable (@K/@LIT unchanged).

Also gives the ASM stream's planned output-to-const rejection for
free — asm `-> @const` lowers through the same path. Regression:
examples/1177-diagnostics-addr-of-const-rejected.sx. Resolves 0138.
2026-06-16 06:29:36 +03:00
agra
c760b92548 issue(0138): @const address-of yields wild pointer; ASM output-to-const BLOCKED
Filed issues/0138: `@const` (address-of a `::` comptime constant) lowers
to `inttoptr (i64 <value> to ptr)` — segfaults on deref, invalid store for
asm `-> @const`. Root cause in src/ir/lower/expr.zig .address_of (not asm).
Marked CHECKPOINT-ASM Next step BLOCKED on 0138 for the output-to-const
rejection item.
2026-06-15 23:18:37 +03:00
agra
97a4050462 docs(asm): checkpoint Phase G — read-write + place outputs 2026-06-15 23:08:24 +03:00
agra
4128416d48 feat(asm): read-write + place outputs
Implements read-write (`+r` / `+{reg}`) `-> @place` outputs. LLVM has
no `+` constraint, so a read-write place lowers to:

  - an output `=` constraint (return slot, stored back through the
    place after the call), with the leading `+` rewritten to `=`; plus
  - a TIED input constraint (the decimal index of that output) appended
    after the regular inputs, seeded with the place's loaded value
    passed as a call arg.

Tied inputs are appended last so existing operand indices (%[name] ->
${N}) are undisturbed; asmOperandIndex stays correct. Lowering no longer
rejects `+` (indirect `*` still rejected). emitInlineAsm grows the
arg/param arrays by the rw count, loads each seed, and emits the tied
constraint.

Verified by running: increment-in-place (41 -> 42) and a mixed case
(rw place + regular input + value output) producing the textbook
"=r,=r,r,0" constraint with correct ${N} indices. 1650 flipped from
the rejection lock to a runnable aarch64-pinned example (ir-only
elsewhere). zig build test green (658 corpus, 446 unit).
2026-06-15 23:07:38 +03:00
agra
335ac52374 test(asm): lock read-write + place-output rejection (Phase G prep)
Adds examples/1650-platform-asm-rw-place.sx exercising a `+r -> @x`
read-write place output. Currently rejected loudly at lowering
("not yet implemented"); this locks that behavior as a passing test.
The next commit implements read-write outputs and flips this example
to run end-to-end (increment-in-place → 42).
2026-06-15 23:00:48 +03:00
agra
967005621a feat(asm): Phase 2 — -> @place write-through outputs
An asm result can be STORED through a place (a local / struct field) instead of
returned; the place output does not join the result tuple.

- parser.zig: `-> @place` parses `@place` as an ordinary address-of expression
  → an out_place operand (the in-function form; reuses the existing `@` prefix).
- inst.zig: AsmOperand gains out_ty (the output slot's value type) so emit can
  build the combined return struct without re-deriving from Inst.ty.
- lower/expr.zig: out_place operand = the lowered @place address, out_ty = the
  pointee. Read-write (`+`) and indirect-memory (`*`) constraints rejected loudly
  (not yet implemented) rather than miscompiled.
- ops.zig emitInlineAsm: the LLVM return type is built from ALL outputs
  (out_value + out_place); after the call, out_place slots are stored through
  their address and out_value slots rebuild the sx result. Fast path when there
  are no place outputs (the struct return IS the result — pure-value asm IR
  unchanged).

Verified: write-to-local (42), struct field, mixed value+place (v=10 b=20), `+`
rejected. Locked with 1649-platform-asm-place-output (mixed, runs on aarch64).

zig build test green (657 corpus, 446 unit).
2026-06-15 22:47:34 +03:00
agra
b8800a234c docs(asm): add Inline Assembly section to readme
Documents the `asm { … }` expression (template + `-> Type` / `= expr` operands +
clobbers), the §II.5 auto-naming rule (register pin → implicit name; echo form
rejected), the result-shape rule (0→void+volatile / 1→T / N→tuple), `#string`
multi-instruction templates, and top-level global asm + lib-less `extern`
call-into. Per the docs-track-changes rule (inline asm is a landed user-facing
feature). Examples are ones verified running in the corpus.
2026-06-15 22:28:10 +03:00
agra
4d75b9323c feat(asm): Phase F — global (module-scope) asm
A top-level `asm { "tmpl", };` block (template only) lowers to LLVM `module asm`;
a lib-less `extern` declaration calls into the symbols it defines (the import
direction reuses the existing C-FFI extern path — no new surface).

- ast.zig: asm_global node (AsmGlobal { template }).
- parser.zig: parseAsmGlobal, dispatched from parseTopLevel on kw_asm — rejects
  `volatile` and any operands/clobbers (template only). The in-function asm
  expression form stays in parsePrimary.
- module.zig: Module.global_asm list; lower/decl.zig captures each template in
  lowerMainAndComptime (the real top-level pass — lowerDecls is dead for
  top-level); emit_llvm.zig emit() appends each via LLVMAppendModuleInlineAsm in
  source order.
- the new node forced asm_global arms in sema.zig (analyzeNode +
  findNodeAtOffset) and semantic_diagnostics.zig (checkBindingNames).

Verified end-to-end: an aarch64 `_my_add` global routine, called via `extern`,
returns 42 — AOT only (the ORC JIT doesn't link module-asm symbols; global-asm
symbols live in the final linked binary). Locked with 1648-platform-asm-global
({ "aot": true, "target": "macos" } → AOT build+run on aarch64, ir-only else).

zig build test green (656 corpus, 446 unit).
2026-06-15 22:22:29 +03:00
agra
d3c6ffed5a feat(asm): Phase E — multi-output asm returns tuples
Replaces the N>1 "Phase E" bail with a shared asmResultType helper (lowering +
inferType) that derives the result type from the out_value operands: 0→void,
1→T, N→a named tuple (fields named via the §II.5 effective-name rule).

Key realization: toLLVMType(tuple) already produces a literal struct {T1,…,Tn} —
exactly what LLVM's multi-output inline asm returns — so emit needs NO change.
Building the op with a tuple result type makes the asm call return the struct,
which IS sx's tuple value (destructured by the normal tuple_get path).

inferType's .asm_expr arm now also delegates to asmResultType (single owner), so
`return asm`, `x := asm`, and `q, r := asm` all agree on the type.

Verified end-to-end on aarch64: split(0x1234)→(lo=52,hi=18), a udiv/msub
divmod→(3,2). IR: `call { i64, i64 } asm "divq ${4}",
"={rax},={rdx},{rax},{rdx},r,~{cc}"(…)` → extractvalue → tuple.

1640 → the x86_64 multi-output IR lock (ir-only); 1647 → a multi-output example
that runs on aarch64.

zig build test green (655 corpus, 446 unit).
2026-06-15 21:55:38 +03:00
agra
5a5e04c6d5 feat(asm): Phase C.1 + D — inline asm codegen (runs end-to-end)
lowerAsmExpr stops bailing and builds the inline_asm op: resolves each operand's
effective name (§II.5 — explicit [name] else the {reg} pin), interns
template/constraints/clobbers, lowers input Refs, derives the result TypeId
(0→void, 1→T). Adds the last deferred validation (every %[name] must name an
operand). Multi-output (N>1) bails with a named "Phase E" diagnostic.

emitInlineAsm (backend/llvm/ops.zig) ports Zig's airAssembly: assembles the LLVM
constraint string (outputs → inputs → ~{clobber}, ',' → '|'), rewrites the
template (%[name]→${N}, %%→%, $→$$, %=→${:uid}), then LLVMGetInlineAsm +
LLVMBuildCall2 (AT&T dialect). Dispatch wired in emit_llvm.zig (replacing the C.0
@panic tripwire).

inferType gains an .asm_expr arm (expr_typer.zig) so a bare `x := asm {…-> T}`
binding types correctly — without it the binding inferred .unresolved and
silently produced 0.

llvm_shim.c: LLVMInitializeNativeAsmParser() — the JIT must assemble inline asm
at run time.

Verified end-to-end on the aarch64 host: `mov`/`add` with register-class inputs
and a value output run (exit 42/99), `nop volatile` runs (exit 0). IR is
textbook: `call i64 asm "add ${0},${1},${2}", "=r,r,r"(…)`.

Locked with 1645 (aarch64 add, runs; ir-only on non-aarch64) + 1646 (:= binding).
Updated 1640 (now Phase-E bail) + 1642 (now runs).

zig build test green (654 corpus, 446 unit).
2026-06-15 21:39:54 +03:00
agra
6c08de8ec1 feat(asm): Phase C.0 — add inline_asm IR op (lock, no behavior change)
Adds the `inline_asm: InlineAsm` opcode to the IR Op union (inst.zig): interned
template + operand list (role/name/constraint/operand) + interned clobber names
+ has_side_effects; the result rides on Inst.ty (void / scalar / tuple).

The new variant forces coverage in the exhaustive Op switches:
- interp.zig: loud bailDetail — inline asm is never comptime-evaluable.
- print.zig: an IR-dump arm.
- emit_llvm.zig: a @panic TRIPWIRE — emit lands in Phase D, and until then
  lowerAsmExpr still bails, so no inline_asm op is ever created. Reaching emit
  would mean lowering switched over before emit was ready; crash loudly rather
  than miscompile.

No behavior change: lowering still bails, the op is constructed only in the new
`inline_asm op shape` unit test (inst.test.zig).

zig build test green (652 corpus, 446 unit).
2026-06-15 21:00:12 +03:00
agra
5f444aae26 feat(asm): Phase B.1 — operand-name validation (echo + duplicates)
Extends lowerAsmExpr with a pinnedRegister(constraint) helper and two §II.5
operand-naming checks, in the compile path before the codegen bail:

- reject the echo form `[eax] "={eax}"` — a label identical to the register its
  own constraint pins is redundant (the operand is already auto-named after the
  register); the useful form is a label that differs (`[quot] "={rax}"`);
- reject duplicate operand names (ambiguous %[name] / result field).

Locked with 1643-platform-asm-echo-name and 1644-platform-asm-duplicate-name.

zig build test green (652 corpus, 445 unit).
2026-06-15 20:41:41 +03:00
agra
1040b8c776 feat(asm): Phase B.0 — validate asm shape in the compile path
Restructures the .asm_expr lowering arm into lowerAsmExpr, which validates the
asm shape with specific named diagnostics BEFORE the not-yet-implemented codegen
bail, so the user sees the real problem first. Two checklist items enforced:

- template must be a compile-time-known string ("..." or #string), not a
  runtime expression;
- an asm with no value outputs must be `volatile` (else its effects could be
  deleted) — mirrors Zig's rule.

Valid shapes still bail with the "codegen not yet implemented" message. Result-
type derivation + the operand auto-naming rule stay deferred to Phase C, where a
real IR op makes the result type observable/testable.

Locked with 1641-platform-asm-missing-volatile (the volatile error) and
1642-platform-asm-nop-volatile (no-output + volatile accepted → codegen bail).

zig build test green (650 corpus, 445 unit).
2026-06-15 20:35:43 +03:00
agra
f8e029d719 feat(asm): Phase A.1 — parse asm { … } into AsmExpr; loud lowering bail
`asm volatile? { "tmpl", [name]? "constraint" (-> Type | = expr), …,
clobbers(.…) }` now parses into a flat-operand AsmExpr/AsmOperand (ast.zig +
parser.zig parseAsmExpr, dispatched from parsePrimary on .kw_asm). `volatile`
and `clobbers` are recognized contextually (not reserved). `-> @place`
write-through is rejected with a clear "Phase 2" parse error.

Codegen is not implemented yet (IR op + LLVM emit are Phases C–E), so lowering
bails LOUD + named via an explicit .asm_expr arm in lower/expr.zig (not the
generic unknown_expr else) — emitPlaceholder makes hasErrors() abort the build
on the message.

The new asm_expr tag forced (and got) arms in three exhaustive Node.Data
switches: sema.zig analyzeNode + findNodeAtOffset, semantic_diagnostics.zig
checkBindingNames — each recurses into template + operand payloads.

Design: adopted the operand auto-naming rule (design §II.5) — name auto-derived
from a {reg} pin, explicit [name] only when it differs or for register-class
operands, echo form rejected. Typing-stage rule; parser stores name: ?[]const u8.

Locked with examples/1640-platform-asm-parse.sx (multi-output divmod: named
operands, register pins, clobbers — parses then bails, called from main).

Also files issue 0137 (pre-existing, orthogonal: `sx run` with no `main`
segfaults via an unguarded JIT entry lookup in target.zig — not an asm bug).

zig build test green (648 corpus, 445 unit).
2026-06-15 20:21:25 +03:00
agra
3c9ecd0b42 feat(asm): Phase A.0 — add kw_asm keyword + lex test
`asm` now lexes as a dedicated `kw_asm` keyword (Token.Tag + keyword map entry).
`volatile` and `clobbers` stay out of the global keyword table — they are
recognized contextually only inside an `asm { … }` body (PLAN-ASM Deviation 4).

- token.zig: kw_asm tag + `.{ "asm", .kw_asm }` map entry.
- lsp/server.zig: classifyToken exhaustive switch gained the .kw_asm arm
  (the new enum value forced coverage — intended tripwire).
- lexer.test.zig (new, wired into root.zig barrel): locks `asm`->kw_asm and
  `volatile`/`clobbers`->identifier.

Lock commit (behavior-locking passing test). zig build test green (445 unit).
2026-06-15 18:32:34 +03:00
agra
c92d11e748 docs(asm): Phase 0.2 — document <name>.build sidecar; Phase 0 complete
CLAUDE.md §Testing + §Test-layout now describe the optional `<name>.build` JSON
config (aot + target keys, ir-only arch-gating, unknown-key-is-error) and list
it among the `expected/` files, replacing the stale standalone `.aot` marker
prose. Closes Phase 0 (corpus target-gating); next is Phase A (kw_asm keyword).
2026-06-15 18:20:33 +03:00
agra
0095584105 test(asm): Phase 0.1 — corpus ir-only branch for cross-target examples
When a `.build` target doesn't match the host, the runner can't execute the
example here, so it verifies via `sx ir --target` only: asserts exit + the `.ir`
snapshot (stdout) + diagnostics (stderr), never `.stdout`. An `.ir` snapshot is
REQUIRED in ir-only mode — its absence is a loud failure, never a silent pass.

- corpus_run.test.zig: ir_only flag (target set & !hostMatchesTarget); first
  dispatch arm runs `sx ir`, sets act_exit/act_err/act_ir; skip stdout in both
  update and verify modes; require ir_raw.
- lock fixture 1639-platform-target-cross (asm-free main, target x86_64-linux,
  checked-in .ir). Verified: corrupt .ir => IR mismatch; delete .ir => require
  failure.

Test-infra only; no compiler code. zig build test green (647 corpus, 444 unit).
2026-06-15 18:19:17 +03:00
agra
c88f4fbcef test(asm): Phase 0.0 — corpus target-gating + .build JSON config
Adds per-example build/run directives to the corpus runner via an optional
`expected/<name>.build` JSON sidecar (`BuildConfig { aot, target }`), replacing
the standalone `.aot` marker. Threads `--target` into the run/build/ir spawns
and gates the execute path on host arch+os match; a cross-target example fails
loudly ("ir-only mode not yet implemented") pending Phase 0.1.

- corpus_run.test.zig: BuildConfig + std.json parse (unknown-key => error),
  hostMatchesTarget (shorthand-expand + arch/os token match, arm64->aarch64),
  withTarget argv helper; unit tests for both.
- migrate 1226/1227 `.aot` markers -> `.build` { "aot": true }.
- lock fixture 1638-platform-target-host (`.build` { "target": "macos" }).

Test-infra only; no compiler code. zig build test green (646 corpus, 444 unit).
2026-06-15 17:37:35 +03:00
agra
d6a9c4f0c4 fix(diagnostics): locate import parse errors in the imported file
A parse error raised while resolving an `#import` was rendered against the
ROOT file's source — the caret landed on an unrelated line (often a comment)
even though the message named the correct imported file.

Two compounding causes:
- core.zig wired `diagnostics.import_sources` only AFTER import resolution
  returned, but a parse error aborts mid-resolution (before that wiring), so
  the renderer had no imported sources and fell back to the root file. Wire it
  (and seed the main-file source) BEFORE resolving.
- imports.zig emitted the diagnostic at the importer's `#import` span instead
  of the parser's actual error offset inside the imported file, and didn't pin
  the diagnostic's source_file to that file.

parser.zig now records `err_end` alongside `err_offset` for a proper caret
width. New `DiagnosticList.addFmtInFile` renders against an explicit source
file; imports.zig uses it with `importErrSpan(&p)`.

Regression test: examples/1176-diagnostics-import-parse-error-location
(importer + deliberately-broken companion; caret must land in the companion).
2026-06-15 15:09:40 +03:00
agra
fe9bd75e09 chore: track *.vsix via Git LFS
Add .gitattributes routing *.vsix through Git LFS and convert the committed
extension vsix to an LFS pointer. Keeps the reproducible build artifact in the
repo without growing normal history on each rebuild. Future-only — existing
vsix blobs remain in history (a `git lfs migrate` rewrite would be needed to
purge those, deferred since origin/master is shared).
2026-06-15 13:39:39 +03:00
agra
8a3bdbe7b5 chore(ffi-linkage): add MIT license to vscode extension
LICENSE file (MIT, © 2025 agra — matching the repo-root LICENSE) +
`license: "MIT"` manifest field + README license section. Clears vsce's
missing-license warning; rebuilt the vsix.
2026-06-15 13:33:21 +03:00
agra
f3c9747f5a chore(ffi-linkage): post-stream polish — vscode keywords + vestigial param + extension metadata
Two post-stream follow-ups flagged in CHECKPOINT-EXTERN-EXPORT.md, plus a
reproducible vscode-extension packaging setup:

- parser: drop the vestigial `RuntimeClassPrefix.is_extern` field and
  `parseRuntimeClassDecl`'s `is_extern` param. Always false since the
  `#foreign` token was deleted; the postfix `extern`/`export` keyword is the
  sole reference-vs-define decider. No behavior change (644 corpus / 442 unit).
- vscode grammar: highlight `extern`/`export` as `storage.modifier.sx`.
- vscode packaging: declare `@vscode/vsce` as a devDep + add `package` /
  `vscode:prepublish` scripts so the vsix rebuilds reproducibly (was an
  ambient tool). Add repository/homepage/bugs (Gitea), icon (swipelab logo,
  256x256), galleryBanner, README with cover banner. Rebuilt the vsix.
2026-06-15 12:57:07 +03:00
agra
c1ab2cbfc0 docs(ffi-linkage): checkpoint — PHASE 9 COMPLETE, FFI-linkage stream DONE (9.4 gate passes) 2026-06-15 11:19:26 +03:00
agra
b9cfe2554f refactor(ffi-linkage): Phase 9.3/9.4 — purge 'foreign' from issues/*.md; GATE PASS
Rewrote 20 issue writeups to the extern/runtime-class vocabulary (#foreign→extern,
foreign_class_map→runtime_class_map, parseForeignClassDecl→parseRuntimeClassDecl,
findForeignMethodInChain→findRuntimeMethodInChain, dedupeForeignSymbol→
dedupeExternSymbol, is_foreign_c_api→is_extern_c_api, stale filename refs to the
renamed examples, foreign-class→runtime-class, bare foreign→extern). Renamed
issues/0043-…-foreign-class-…→…-runtime-class-….

PHASE 9 COMPLETE — 9.4 GATE PASSES: zero 'foreign' across src/library/examples/
issues/docs/editors/specs/readme/CLAUDE, excluding only the SQLite API constant
SQLITE_CONSTRAINT_FOREIGNKEY + vendored sqlite3.c/.h (upstream third-party).
Suite green (644 corpus / 443 unit, 0 failed).
2026-06-15 11:18:35 +03:00
agra
b52d424369 refactor(ffi-linkage): Phase 9.3 — rename *-foreign* example files → extern/runtime names
git-mv the 10 foreign-named example families to extern/runtime-class names + update
every #import/#include/#source ref, stale comment ref, and the 1172 stderr snapshot
(path + 'extern symbol' message). Renames: 0729…-foreign→…-extern, 1172-diagnostics-
foreign-symbol-conflict→…-extern-symbol-conflict, 1205/1207 ffi-foreign-global→
ffi-extern-global, 1216/1217 ffi-…-foreign-(in-method|result-chain)→…-extern-…,
1219-ffi-foreign→1219-ffi-extern, 1306 objc-foreign-class-chained→objc-runtime-class-
chained, 1318 objc-property-foreign→objc-property-extern-class. DEDUP: deleted
1218-ffi-foreign-cvariadic (identical to 1229-ffi-extern-cvariadic; updated 1229's
twin ref) + the orphaned 1620 dir. Also purged editors/vscode tmLanguage (#foreign
dropped from the directive highlighter) + 1220.h/issues-0030.sx comment refs. Suite
green (644 corpus / 443 unit, 0 failed).
2026-06-15 11:14:35 +03:00
agra
9719432e79 refactor(ffi-linkage): Phase 9.3 — purge remaining 'foreign' from library/docs/example comments
Capital-Foreign + stale-identifier comment refs: library (Foreign Java types→Runtime,
foreign-class→runtime-class, foreign_class_map→runtime_class_map); docs/debugger
(foreign call→extern call); docs/fork-c ledger (foreign_class_map, protocol/foreign→
runtime-class); docs/inline-asm-design Deviation-6 obsolete #foreign-vs-extern design
RESOLVED to the landed extern/export reality; example comments (parseForeignClassDecl→
parseRuntimeClassDecl, checkForeignRefs→checkExternRefs, Foreign decls→Extern). Docs/
comments only — no build impact.
2026-06-15 11:03:29 +03:00
agra
dfae690b31 refactor(ffi-linkage)!: Phase 9.0 — delete the hash_foreign token (src is foreign-free)
Per user directive (total purge): remove the hash_foreign token entirely rather than
keep it for a friendly deprecation message. Deleted: the token enum (token.zig), the
lexer keyword entry + directive-list mention + lex test (lexer.zig), the 4 parser
rejection sites + 2 lookahead clauses + the runtime-class prefix #foreign peek arm
(parser.zig), and the lsp completion arm (server.zig). '#foreign' now lexes as an
invalid '#' token → a generic 'expected ;' parse error (no migration hint — the
accepted UX cost of zero-foreign). Deleted examples/1176-diagnostics-foreign-removed
(its purpose, the friendly rejection, no longer exists).

src/ now contains ZERO 'foreign' (case-insensitive). Suite green (645 corpus / 443
unit, 0 failed). Remaining for the 9.4 gate: issues/*.md prose + example filenames.
2026-06-15 10:59:59 +03:00
agra
811a280517 refactor(ffi-linkage): Phase 9.3 — purge 'foreign' from comments (src caps + examples + docs)
src/: ~21 capital-Foreign comments the case-sensitive verify grep missed
(Foreign-class→Runtime-class, Foreign path→Runtime path, Foreign decls→Extern decls,
FOREIGN function→extern function) across calls/inst/ffi_objc/jni_descriptor/emit_llvm/
c_import/lower.*/ops. src 'foreign' now = ONLY the hash_foreign token + 4 rejection
messages (9.0-delete targets). examples/*.sx comments → extern/runtime-class (1219
stdout regen; KEPT 1176). docs/inline-asm-design + debugger purged. Comments only —
no build impact. 9.0 ratified: DELETE hash_foreign token next.
2026-06-15 10:52:56 +03:00
agra
dc51c4b5bf refactor(ffi-linkage): Phase 9.3-src — purge 'foreign' from src/ comments + a user-facing diagnostic
Reword every 'foreign' comment to the extern/runtime-class vocabulary matching the
renamed identifiers (foreign call→extern call, foreign class→runtime class, foreign
path→runtime path, the #foreign-literal comment mentions → extern, etc.). Also fixes
two USER-FACING issues: the 'expected … #foreign … after type annotation' parse error
no longer advertises the removed keyword, and the Android 'no #jni_main' help
diagnostic now shows '#jni_class(…) extern' instead of the rejected '#foreign
#jni_class'. Removed the now-dead prefix-#foreign-vs-postfix conflict branch in
parseRuntimeClassDecl (the caller rejects #foreign before it runs).

src/ now contains 'foreign' ONLY in the hash_foreign token machinery + its 4
rejection messages — the deprecation mechanism (kept per the 9.0 recommendation; the
message MUST name #foreign to guide migration). Snapshot-neutral; suite green
(646 corpus / 444 unit, 0 failed).
2026-06-15 09:35:00 +03:00
agra
e99383fcb4 docs(ffi-linkage): Phase 9.3-docs — purge 'foreign' from specs/readme/CLAUDE
Reword to the extern/runtime-class vocabulary: 'Foreign Function Interface' heading →
'C Interop'; 'foreign class'→'runtime class'; '#import c foreign decls'→'extern decls';
'foreign function calls'→'extern function calls'; the host_ffi #foreign("c") ref →
extern; the bundling 'foreign calls'→'extern calls'. Docs-only; zero 'foreign' left in
specs.md/readme.md/CLAUDE.md.
2026-06-15 09:24:55 +03:00
agra
145b6d8eff docs(ffi-linkage): checkpoint — Phase 9.1+9.2 IDENTIFIER purge COMPLETE; comment/doc/issues text purge remains 2026-06-15 09:21:55 +03:00
agra
8cca3b9dde refactor(ffi-linkage): Phase 9.2d — rename foreign_path → runtime_path (coupled .sx↔.zig↔hook)
The JNI/runtime-class path (Decision 5, Runtime* family). Coordinated across the
hook boundary so the BuildOptions accessor + its registered hook string stay in sync:
- src/: RuntimeClassDecl.foreign_path→runtime_path, splitForeignPath→splitRuntimePath,
  foreignPathToJavaName→runtimePathToJavaName, jni_main_foreign_paths→
  jni_main_runtime_paths, hookJniMainForeignPathAt→hookJniMainRuntimePathAt, and the
  hook string 'BuildOptions.jni_main_foreign_path_at'→'…runtime_path_at'.
- library/: build.sx accessor jni_main_foreign_path_at→jni_main_runtime_path_at +
  bundle.sx call sites + the  local var → runtime_path + a comment.
- specs.md: the accessor name + <foreign_path_with_dots> doc refs.
- Regenerated 37 .ir snapshots: every program importing build declares the renamed
  @BuildOptions.jni_main_runtime_path_at hook stub — symbol-name change only (verified
  the .ir diff is ONLY this rename; reverted orthogonal empty-file normalization).
Suite green (646 corpus / 444 unit, 0 failed).
2026-06-15 09:20:30 +03:00
agra
a15a868391 refactor(ffi-linkage): Phase 9.2b-fix — use is_extern (not new is_reference) for the runtime-class ref flag
Per user feedback: don't introduce new terminology. The RuntimeClassDecl
reference-vs-define flag (set by the postfix 'extern' modifier, == old prefix
'#foreign #objc_class') is named is_extern, matching the keyword that drives it
and the existing is_extern on VarDecl/IR. Renamed is_reference→is_extern,
is_reference_eff→is_extern_eff; updated the field comment. Snapshot-neutral; green.
2026-06-15 09:06:19 +03:00
agra
d27be42a93 refactor(ffi-linkage): Phase 9.2c — rename extern-ref validators → Extern (linkage)
checkForeignRefs→checkExternRefs, validateForeignRefs→validateExternRefs,
collectForeignRefTargets→collectExternRefTargets — these police 'extern LIB' library
references (linkage axis), so Extern not Runtime. Snapshot-neutral; suite green.
2026-06-15 09:03:35 +03:00
agra
5c8af6eb73 refactor(ffi-linkage): Phase 9.2b — rename runtime-class fns + state → runtime_* / is_reference
The runtime-class object-model identifiers (Decision 5): parse/lower/find/resolve/
register/stamp fns Foreign→Runtime (parseRuntimeClassDecl, lowerRuntimeMethodCall,
findRuntimeMethodInChain, resolveRuntimeMethodReturnType, registerRuntimeClassDecl,
runtimeClassStructType, runtimeKindForOffset, …); state foreign_class_map→
runtime_class_map, current_foreign_class/_method→current_runtime_*, the
foreign_class_decl union variant→runtime_class_decl, foreign_method/static/instance/
class→runtime_*; and the reference-vs-define flag is_foreign→is_reference (+
is_foreign_eff→is_reference_eff) now that it only lives on RuntimeClassDecl.
Snapshot-neutral; suite green (646/444).

Remaining 9.2: the foreign_path family (coupled .sx hooks: jni_main_foreign_path_at
spans build.sx/bundle.sx/compiler_hooks.zig/specs.md) + the extern-ref validators
(checkForeignRefs etc. → Extern, linkage not runtime) + bare 'foreign' comments.
2026-06-15 09:01:04 +03:00
agra
3354446412 refactor(ffi-linkage): Phase 9.2a — rename runtime-class TYPE names → Runtime* (Decision 5)
Mechanical, collision-free PascalCase renames (object-model axis, not linkage):
ForeignClassDecl→RuntimeClassDecl, ForeignMethodDecl→RuntimeMethodDecl,
ForeignClassMember→RuntimeClassMember, ForeignFieldDecl→RuntimeFieldDecl,
ForeignRuntime→RuntimeKind, ForeignClassPrefix→RuntimeClassPrefix. Snapshot-neutral;
suite green (646/444). Remaining 9.2: snake_case state (foreign_class_map,
current_foreign_class, foreign_path [coupled to .sx hooks], the foreign_class_decl
union variant) + the parse/lower/resolve fn names + ForeignClassDecl.is_foreign flag.
2026-06-15 08:57:53 +03:00
agra
7ffdc7d2a2 refactor(ffi-linkage): Phase 9.1d — eliminate the foreign_expr AST node
The last linkage-family 'foreign' carrier. Migrated c_import.zig auto-synthesis
(#import c {#include}) to build the extern shape (empty-block body + extern_export
= .extern_) instead of a foreign_expr body — the Phase 5.0 fn-body flip applied to
auto-synth. With nothing left building it, deleted the foreign_expr union variant +
ForeignExpr struct (ast.zig) and every reader: the dead-arm switch cases (sema,
resolver, generic, call, semantic_diagnostics, lsp), the coalescing reads in
decl.zig (is_foreign local, cc/rename/dedup/variadic/visibility gates) + pack.zig,
and checkForeignRefs (now reads extern_lib only). 9.1 LINKAGE PURGE COMPLETE — all
that remains in src/ is the runtime-class family (9.2) + comments. Snapshot-neutral
(the #import c examples 1215/1216/1217 + sqlite 1624 exercise the synth path); suite
green (646 corpus / 444 unit, 0 failed).
2026-06-15 08:54:56 +03:00
agra
98264b8640 docs(ffi-linkage): checkpoint — Phase 9.1a/b/c done (linkage purge started); collision analysis + scoped gate + ordered remaining plan 2026-06-15 08:48:33 +03:00
agra
cd147942e4 refactor(ffi-linkage): Phase 9.1c — delete dead VarDecl legacy foreign fields
VarDecl carried BOTH the legacy is_foreign/foreign_lib/foreign_name AND the new
is_extern/extern_lib/extern_name (parallel forms coalesced during the migration).
The global #foreign parse path now rejects, so the legacy trio is write-dead and
read in only 3 coalescing sites (decl.zig). Simplified those readers
(vd.extern_name orelse vd.name; vd.is_extern) and deleted the dead fields. Build
confirms no other setter/reader. Snapshot-neutral; suite green (646/444).

Remaining linkage (9.1): foreign_expr (25, still built by c_import.zig auto-synth)
+ ForeignClassDecl.is_foreign (runtime-class, → 9.2). Runtime-class family (9.2,
Decision 5) is the big remaining src/ rename.
2026-06-15 08:46:59 +03:00
agra
b78e7ddeb1 refactor(ffi-linkage): Phase 9.1b — rename 'foreign symbol' diagnostic + panic to 'extern'
The dup-C-symbol diagnostic (decl.zig) and the resolveFuncByName panic (call.zig)
now say 'extern symbol' instead of 'foreign symbol' — the keyword-neutral internal
wording catches up to the extern-only surface. Intentional snapshot regen of 1172
(the only assertion of this message). Suite green (646/444).
2026-06-15 08:42:59 +03:00
agra
b838f6383f refactor(ffi-linkage): Phase 9.1a — rename collision-free linkage identifiers
Mechanical src/ rename of the linkage-family identifiers whose extern_* target is
collision-free: callForeign→callExtern, marshalForeignArg→marshalExternArg,
dedupeForeignSymbol→dedupeExternSymbol, foreign_name_map→extern_name_map,
is_foreign_c_api→is_extern_c_api. Snapshot-neutral (internal only); suite green
(646 corpus / 444 unit, 0 failed).

Deferred (need per-site analysis — target name already exists): is_foreign↔is_extern
(38 existing), foreign_lib/foreign_name↔extern_lib/extern_name (15/16 existing),
foreign_expr (still built by c_import.zig auto-synthesis). Runtime-class family
(ForeignClassDecl etc. → Runtime*, Decision 5) is Phase 9.2.
2026-06-15 08:39:59 +03:00
agra
7ca074e1b0 docs(ffi-linkage): checkpoint — PHASE 8 COMPLETE (cutover); Decision 6 ratified (purge everything); Phase 9 next 2026-06-15 08:08:33 +03:00
agra
3811311e12 feat(ffi-linkage)!: Phase 8.1 — parser hard-rejects #foreign (cutover)
The prefix #foreign linkage directive is removed. All four parse sites
(const-with-type, data global, fn body, runtime-class prefix) now reject it with
a migration message ('#foreign has been removed; use the postfix extern (import) /
export (define) linkage keyword instead'); added a span-aware failAt for the
runtime-class case (the lookahead consumes the token before the reject decision).
Greens the Phase 8.0 xfail 1176.

- Deleted obsolete tests: 1174 (#foreign+postfix conflict — unreachable now that
  #foreign alone is rejected) and 1620 (#foreign nosuchunit lib-ref — superseded by
  the extern twin 1231). Their assertions tested #foreign-specific behavior.
- Removed the GATE A→B unit test + lowerSrcToIr helper (lower.test.zig): it locked
  #foreign ≡ extern through the migration; with #foreign gone there is nothing to
  compare. Converted the in-source 'parse void function with foreign body' parser
  test to the surviving postfix 'extern' spelling (identical resulting AST).
- specs.md + readme.md drop #foreign; document extern/export as the sole C-linkage
  surface.

extern_export in parseFnDecl is now const (the fn-body arm that mutated it is gone).
Suite green (646 corpus / 444 unit, 0 failed). NOTE: comment-only #foreign in
examples + issues/*.md prose + internal foreign_* identifiers remain for Phase 9
(now unblocked: Decision 6 = purge everything).
2026-06-15 08:06:05 +03:00
agra
8180faf839 test(ffi-linkage): Phase 8.0 — xfail #foreign-removed rejection diagnostic
Add examples/1176-diagnostics-foreign-removed.sx pinning the DESIRED Phase 8 cutover
behavior: a bare '#foreign' decl must be rejected with a clear migration message
('#foreign has been removed; use the postfix extern/export'). RED — '#foreign' still
parses (routes onto extern) so the decl compiles and exits 0 instead of erroring.
The very next commit (8.1, parser hard-reject) greens it.
2026-06-15 07:40:12 +03:00
agra
d132aab232 refactor(ffi-linkage): Phase 8 pre-cutover — migrate multi-file example companions
The Phase 7 batches globbed top-level examples/*.sx and missed #foreign decls in
SUBDIRECTORY companion files. Migrate the 4 incidental fn decls (behavior-preserving):
0729/a.sx + b.sx (same-name 'absval' libc authors), 1617/c.sx (pcap_lib_version),
1623/mod.sx (unit_in_mod_answer) — all '#foreign LIB "csym";' → 'extern LIB
"csym";'. Parents all marker'd → corpus-validated; empty snapshot diff. Comment
lines left for Phase 9.3. Suite green (647/444).
2026-06-15 07:36:57 +03:00
agra
720556b24e refactor(ffi-linkage): Phase 8 pre-cutover — migrate keyword-neutral diagnostic tests
Migrate the two #foreign-bearing diagnostic tests whose assertions survive the
cutover, with INTENTIONAL snapshot regens (reviewed):
- 1172 (foreign-symbol-conflict): decl '#foreign libc "getenv"' → 'extern libc
  "getenv"'. Still tests the dup-C-symbol conflict; the 'foreign symbol already
  bound' message is the keyword-neutral INTERNAL wording (renamed to 'extern symbol'
  in Phase 9.1), so it persists — only the echoed source line + caret moved.
- 1228 (non-transitive C-import visibility): its identity was the #foreign≡extern
  equivalence lock, now historical (structural via the A→B gate + unified AST). The
  identifier 'c_foreign_abs' itself contained 'foreign' (would fail the Phase 9.4
  gate), so converted c.sx/b.sx/main to two foreign-free extern symbols
  (c_abs_one/c_abs_two); still pins per-symbol non-transitive visibility.

Reverted the orthogonal 0→1-byte empty-stdout normalization on 1228/1231 (known
writeGolden idempotency quirk, not a behavior change). Suite green (647/444).
2026-06-15 07:34:23 +03:00
agra
2cce6a3a26 refactor(ffi-linkage): Phase 8 pre-cutover — migrate identity ffi-foreign-* test decls
Migrate the DECLS of the 7 identity-#foreign feature tests to extern/export
(1205-global/-helper, 1207, 1218-cvariadic, 1219, 1306, 1318): fn/global markers →
extern, the 2 objc import classes (1306/1318) → postfix '#objc_class("X") extern {'.
Behavior-preserving (A→B gate + existing extern twins guarantee identical output);
empty snapshot diff, corpus-validated. Comment-only #foreign in these files is left
for the Phase 9.3 doc/comment purge (comments aren't parsed → not cutover-critical).
Suite green (647 corpus / 444 unit, 0 failed).
2026-06-15 07:28:20 +03:00
agra
8b91677a1b docs(ffi-linkage): checkpoint — Phase 7 migratable work COMPLETE (7.1-7.4); next Phase 8 cutover 2026-06-15 07:05:11 +03:00
agra
1a8991ab27 refactor(ffi-linkage): Phase 7.4 — migrate straggler examples #foreign→extern
16 fn/global examples across categories (0415/0602/0603/1024/1025/1605/1607-1609/
1611/1616/1619/1622/1628/1635/1636): bare '#foreign'→'extern'. All cls=0 (no class
forms). Marker'd ones (1605/1609/1611 + the rest) corpus-validated; the 3 unmarked
uikit importers (1607/1608/1616) verified byte-identical via 'sx ir' probes.
Empty snapshot diff; suite green (647 corpus / 444 unit, 0 failed).

LEFT comment-only/provenance #foreign (0716/0729 + issues/0030-extern-global +
extern-test files 1223-1231/1332/1348/1349/1426) and the keep-list (identity
ffi-foreign-* + foreign-asserting diagnostics 1172/1174/1219/1228/1620) for Phase 8.
2026-06-15 07:03:53 +03:00
agra
2888f6fc00 refactor(ffi-linkage): Phase 7.3 — migrate 14xx ffi-jni examples #foreign→extern
13 JNI examples migrated (1410-1419/1423/1424/1425): import runtime classes
'#foreign #jni_class("X") {' → '#jni_class("X") extern {'. 1417 (all-runtimes)
also exercises #jni_interface/#objc_class/#objc_protocol/#swift_class/#swift_struct/
#swift_protocol — all take the postfix modifier (verified by probe), migrated via a
generalized '#foreign #<directive>("X") {' → '… extern {' rewrite. No 14xx snapshot
asserts on 'foreign'; empty snapshot diff, corpus-validated.

KEPT comment-only #foreign in 1426 (jni-extern-class test, no decls). Suite green
(647 corpus / 444 unit, 0 failed).
2026-06-15 07:00:32 +03:00
agra
a68f7c2e64 refactor(ffi-linkage): Phase 7.2 — migrate 13xx ffi-objc examples #foreign→extern
18 obj-c examples migrated (1308/1311-1317/1319/1320/1321/1341-1347): import
runtime classes '#foreign #objc_class("X") {' → '#objc_class("X") extern {'
(prefix→postfix) + fn/comment '#foreign'→'extern'. No 13xx snapshot asserts on
'foreign' text → all behavior-preserving; empty snapshot diff, corpus-validated.

Per the keep-list policy: KEPT identity-#foreign tests 1306/1318 (filename
ffi-*-foreign*); LEFT comment-only #foreign in the extern/export test files
1332/1348/1349 (no decls). Bare defined #objc_class examples (no #foreign) untouched
— not a purge target. Suite green (647 corpus / 444 unit, 0 failed).
2026-06-15 06:53:33 +03:00
agra
496390e442 docs(ffi-linkage): checkpoint — Phase 7.1 done + keep-list policy; next Phase 7.2 (13xx objc) 2026-06-15 06:50:26 +03:00
agra
731fb8de64 refactor(ffi-linkage): Phase 7.1 — migrate incidental 12xx ffi examples #foreign→extern
12 plain-C examples that use #foreign incidentally (as FFI plumbing, output
unchanged): 1200/1206/1209-1215/1220/1221/1222. Blanket keyword swap; all fn/global
markers (no class forms in 12xx). Empty snapshot diff; corpus validates directly
(all marker'd). Suite green (647 corpus / 444 unit, 0 failed).

KEPT on #foreign (deferred to Phase 8 cutover): identity-#foreign feature tests
(filename ffi-foreign-*: 1205/1207/1216/1218/1219), the equivalence test 1228, and
the diagnostics that assert on #foreign source/message (1172/1174/1620). Comment-only
provenance prose (1223/1229/1230/1231) left intact per Decision-6-recommended.
2026-06-15 06:49:36 +03:00
agra
d3425fa287 docs(ffi-linkage): checkpoint — Phase 6.4+6.5 done; PHASE 6 COMPLETE (library/ #foreign-free); next Phase 7 2026-06-15 06:25:16 +03:00
agra
32a7628297 refactor(ffi-linkage): Phase 6.5 — migrate gpu/ #foreign→extern; library/ now #foreign-free
Final Phase 6 batch: gpu/gles3.sx (eglGetProcAddress + 1 comment) and gpu/metal.sx
(MTLCreateSystemDefaultDevice), bare fn markers → 'extern'. Verified byte-identical
'sx ir' on importers 1610 (gles3 via uikit GLView) + 1606 (metal). **PHASE 6
COMPLETE — zero #foreign remains anywhere under library/.** Empty snapshot diff;
suite green (647 corpus / 444 unit, 0 failed). Next: Phase 7 (examples + issues).
2026-06-15 04:48:30 +03:00
agra
666a2e20e1 refactor(ffi-linkage): Phase 6.4 — migrate ffi/ #foreign→extern
Pure source rename across objc/objc_block/raylib/sdl3/wasm (~51 sites): fn-decl
markers (bare / 'objc' LIB ref) → 'extern …', and objc.sx's 2 import runtime
classes '#foreign #objc_class("X") {' → '#objc_class("X") extern {'. No bare
defined classes. Behavior-preserving. objc + objc_block validated directly by the
50 marked 13xx corpus examples (incl. import classes 1300/1301 + defined classes
1339/1349); raylib/ffi-sdl3/wasm (no marked importers on host) verified by
byte-identical 'sx ir' probes pre/post. Empty snapshot diff; suite green (647
corpus / 444 unit, 0 failed).
2026-06-15 04:45:55 +03:00
agra
48a8769d19 docs(ffi-linkage): checkpoint — Phase 6.3 (std) done; next Phase 6.4 (ffi) 2026-06-15 04:36:28 +03:00
agra
59f90d2939 refactor(ffi-linkage): Phase 6.3 — migrate std/ #foreign→extern
Pure source rename across 11 std modules (~60 sites): cli/core/fmt/fs/log/
net/kqueue/process/socket/thread/time/trace. All fn-decl markers — bare
'#foreign;', '#foreign libc;'/'#foreign tlib;' (LIB ref), and
'#foreign libc "csym";' (LIB+rename) → the same 'extern …' tail (extern carries
the identical [LIB] ["csym"] axis). Plus 2 stale comment mentions (fmt/fs).
No class forms in std. These modules ARE host-corpus-exercised, so the empty
snapshot diff is direct validation. Suite green (647 corpus / 444 unit, 0
failed).
2026-06-15 04:35:52 +03:00
agra
0fbcee7e36 docs(ffi-linkage): checkpoint — Phase 6.2 (platform) done; next Phase 6.3 (std) 2026-06-15 04:33:06 +03:00
agra
2cd5d7ba82 refactor(ffi-linkage): Phase 6.2 — migrate platform/ #foreign→extern/export
Pure source rename across uikit/android/android_jni/sdl3 (~64 #foreign sites):
- 30 fn decls '… #foreign;' → '… extern;'
- 34 import runtime classes '#foreign #objc_class/#jni_class("X") {' →
  '#objc_class/#jni_class("X") extern {' (prefix → postfix modifier)
- 4 defined Sx* obj-c classes '#objc_class("X") {' → '… export {'

Behavior-preserving (AST already unified post-Phase-5.0). Verified byte-identical
IR via 'sx ir' on the uikit importers 1610 + 1606 (which compile uikit incl. the
4 defined Sx* classes on host) and an sdl3 probe; android.sx (host-incompatible,
only compiles under OS==.android) verified by an identical 4-error dedup set (the
keyword-neutral 'foreign symbol already bound' message is unchanged). Empty
snapshot diff; suite green (647 corpus / 444 unit, 0 failed).
2026-06-15 04:32:20 +03:00
agra
32e83c90cc docs(ffi-linkage): checkpoint — Phase 6.1 (sqlite) done; next Phase 6.2 (platform) 2026-06-15 04:26:12 +03:00
agra
410a52e4ca refactor(ffi-linkage): Phase 6.1 — migrate vendors/sqlite #foreign→extern
Pure source rename: all 97 'sqlite3_* ... #foreign sqlib "csym";' fn decls
→ 'extern sqlib "csym";' (+ the one stale header-comment reference). The
extern_lib axis references the 'sqlib' #import c unit identically to #foreign
sqlib, so IR/output is byte-identical. Empty snapshot diff; example 1624
(vendor-sqlite-module) stdout byte-unchanged. Suite green (647 corpus / 444
unit, 0 failed).
2026-06-15 04:25:34 +03:00
agra
346d4a81c3 docs(ffi-linkage): checkpoint — Phase 5.1 done; PHASE 5 COMPLETE, next Phase 6 2026-06-15 04:22:24 +03:00
agra
93e7b6f727 test(ffi-linkage): Phase 5.1 — annotate A→B gate post-flip + add fn-rename case
Phase 5.0 flipped the fn-decl and data-global #foreign parser paths onto the
same extern-named AST that postfix extern produces, so the A→B gate's fn/global
cases are now STRUCTURALLY identical (guaranteed by construction, not empirically
equal). Annotate the gate header to record this and keep it as a regression
tripwire against a future reader re-diverging the two spellings or a revert of
the flip. Add a fn-rename case (extern_name axis: c_abs -> "abs") to broaden
coverage beyond bare import. Test-only; suite green (647 corpus / 444 unit, 0
failed). PHASE 5.1 COMPLETE → PART B Phase 5 done; next Phase 6 (migrate stdlib).
2026-06-15 04:21:36 +03:00
agra
bde284ee21 docs(ffi-linkage): checkpoint — Phase 5.0 parser routing COMPLETE (fn-body flip landed)
fn-decl #foreign body marker now builds the unified extern AST. All four
#foreign parser paths resolved (global + fn-body flipped; const-with-type
dead; runtime-class already coalesced). Decision 7 ratified (accept churn).
Next: Phase 5.1 (confirm A->B gate covers post-flip) then Phases 6-7
(source #foreign->extern rename in stdlib + examples).
2026-06-15 04:05:04 +03:00
agra
6b94bb6bba refactor(ffi-linkage): Phase 5.0 — flip fn-decl #foreign body marker onto the extern AST
The fn-body `#foreign [LIB] ["csym"]` marker now builds the SAME shape postfix
`extern` produces — extern_export = .extern_ + extern_lib/extern_name + an
empty-block body — instead of a `foreign_expr` body. With all four prereqs
landed (visibility, variadic, plain-free classification, lib-ref validation),
every downstream reader coalesces is_foreign with extern_export, so the IR and
runtime behavior are byte-identical (full corpus + the A->B gate stay green).

The surface keyword is no longer on the AST, so a `#foreign`-spelled decl now
yields `extern`-worded diagnostics — the single accepted churn (Decision 7):
example 1620's lib-ref error flips '#foreign library' -> 'extern library'.
Parser-surface diagnostics (conflict/expected-token) fire on the literal keyword
and are unaffected. c_import auto-synthesis still emits foreign_expr bodies (not
this step), so both shapes still coexist. Parser unit test updated to assert the
extern shape.

647 corpus / 444 unit, 0 failed. The const-with-type (dead) + runtime-class
(already coalesced) paths need no flip — Phase 5.0 parser routing is complete.
2026-06-15 04:03:51 +03:00
agra
4dca38881e docs(ffi-linkage): checkpoint — prereqs 3 & 4 done (4/4); fn-body flip de-risked, Decision 7 open
Plain-free classification + extern lib-ref validation closed (the 3rd and
4th extern/#foreign divergences). All four fn-path prereqs now done. The
fn-decl #foreign->extern flip is scoped: IR zero-churn, only example 1620's
lib-ref wording churns. Records Decision 7 (interim diagnostic wording) as
the one gate before executing the flip.
2026-06-15 03:55:09 +03:00
agra
ad6aed3d7a fix(ffi-linkage): Phase 5.0 prereq — validate extern LIB refs like #foreign
checkForeignRefs now reads a library reference from either spelling — the
legacy #foreign body (foreign_expr.library_ref) or the new extern keyword
(extern_lib) — and validates both against the declared #library / #import c
units. The diagnostic names the surface keyword the user wrote (#foreign vs
extern), so example 1620 (#foreign) is byte-unchanged and example 1231
(extern) gets the parallel 'extern library ... not declared'. Greens 1231.

647 corpus / 444 unit, 0 failed.
2026-06-15 03:53:03 +03:00
agra
38c32400f5 test(ffi-linkage): Phase 5.0 prereq — xfail extern undeclared-library ref unvalidated
An `extern LIB "csym"` ref must name a declared #library / #import c unit,
like its `#foreign LIB` twin (example 1620). Today checkForeignRefs reads
only foreign_expr.library_ref and skips the extern keyword's extern_lib, so
a bogus `extern nosuchunit "abs"` compiles silently (the symbol resolves
via the default image and runs). Expected pins the DESIRED compile-time
diagnostic; the next commit extends checkForeignRefs to green it. Fourth
extern/#foreign divergence and a prerequisite for the fn-decl migration.

647 corpus (1231 xfail), 444 unit.
2026-06-15 03:53:03 +03:00
agra
3c94c14b5e fix(ffi-linkage): Phase 5.0 prereq — exclude extern imports from plain-free-fn classification
isPlainFreeFn / isPlainFreeFnDecl excluded a #foreign body but classified
an empty-block extern fn as a plain free function, so existing extern fns
were wrongly counted in the bare-call ambiguity verdict (and eligible for
the out-of-line-slot / shadow-author pass). Both predicates now also
exclude extern_export == .extern_ (an external C symbol with no
sx-lowerable body, name-keyed first-wins dispatch like #foreign); export
keeps a real body and stays plain-free. Greens example 1230 — same-name
extern authors compile like their #foreign twins (0729).

646 corpus / 444 unit, 0 failed.
2026-06-15 03:46:34 +03:00
agra
270652186e test(ffi-linkage): Phase 5.0 prereq — xfail extern same-name authors wrongly ambiguous
Two flat imports each declare `absval` via `extern libc "abs"` (the
`extern` twin of example 0729's `#foreign` form). Like its #foreign twin,
this must compile + run (prints 7), not error as an ambiguous bare-call
collision.

Today `isPlainFreeFn` / `isPlainFreeFnDecl` exclude a `#foreign` body but
classify an empty-block `extern` fn as a plain free function, so the two
extern authors ARE counted in the bare-call ambiguity verdict and the call
errors. A third extern/#foreign divergence (after visibility + variadic)
and a prerequisite for migrating the fn-decl `#foreign` path onto `extern`.

646 corpus (1230 xfail), 444 unit.
2026-06-15 03:43:18 +03:00
agra
b5411efeb8 docs(ffi-linkage): checkpoint — extern C-variadic prereq DONE; both fn-path prereqs cleared
Next step is the fn-decl #foreign body-marker migration onto extern
(behavior-preserving single refactor commit; lowering + both prereq
gates already coalesce is_foreign with extern_export).
2026-06-14 21:06:35 +03:00
agra
0fdc82154f fix(ffi-linkage): Phase 5.0 prereq — map extern C-variadic tail to ... like #foreign
Two gates were keyed on the `#foreign` (foreign_expr) body shape only:
- declareFunction: the is_variadic drop (decl.zig) — a variadic extern
  kept its trailing slice param in the IR signature.
- packVariadicCallArgs: the call-site early-out (pack.zig) — extras were
  slice-packed instead of passed through the C `...` slot.

Both now also fire for `extern_export == .extern_`, so a variadic
`extern` drops the trailing `..args: []T`, sets is_variadic, and passes
extras through the C ABI with default argument promotion — byte-identical
to its `#foreign` twin. Greens example 1229.

645 corpus / 444 unit, 0 failed.
2026-06-14 21:05:40 +03:00
agra
9a2c78d6b9 test(ffi-linkage): Phase 5.0 prereq — xfail extern C-variadic tail loses its ...
A trailing `..args: []T` on an `extern` fn must map to the C `...` tail
like its `#foreign` twin (example 1218). Today the variadic handling in
both declareFunction (is_variadic drop) and packVariadicCallArgs
(call-site early-out) is gated on `#foreign` only, so a variadic
`extern` keeps the trailing slice param and slice-packs the extras —
garbage at the C ABI (probe: sum_ints(3,10,20,30) → 53316585, not 60).

Example 1229 pins the DESIRED correct output; the next commit extends
both gates to cover extern and greens it. Prerequisite for migrating the
fn-decl `#foreign` path onto `extern`.

645 corpus (1229 xfail), 444 unit.
2026-06-14 21:03:13 +03:00
agra
28d38f2f2f docs(ffi-linkage): checkpoint — Phase 5.0 visibility-gate prereq DONE; const-with-type/runtime-class findings
- Mark deferred prereq (b) visibility-gate equivalence CLOSED (1228).
- Record const-with-type as a dead path (deferred per user) and the
  runtime-class prefix as already-coalesced (no Phase 5.0 change).
- Next step is the fn-path variadic prerequisite.
2026-06-14 20:58:31 +03:00
agra
7d8ba1aabc fix(ffi-linkage): Phase 5.0 prereq — police lib-less extern like #foreign in c_import_bare gate
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.
2026-06-14 20:57:19 +03:00
agra
717c35d26d test(ffi-linkage): Phase 5.0 prereq — xfail lib-less extern/#foreign C-import visibility equivalence
Cross-module example (main → b → c) referencing c's lib-less C imports
transitively. The non-transitive C-import gate (lower/decl.zig
c_import_bare) must police the legacy `#foreign` form and the new
`extern` keyword IDENTICALLY — same 'C function not visible' diagnostic,
not the generic top-level-name wording. Today the extern twin escapes the
c_import_bare gate (body is an empty block, not foreign_expr) and is only
caught by the general isNameVisible gate, yielding the generic message.
Expected snapshot pins the DESIRED equivalent wording; the next commit
aligns the gate to green it. Prerequisite for migrating the fn-decl
`#foreign` path onto `extern`.

443/444 corpus (1228 xfail), 444 unit.
2026-06-14 20:47:48 +03:00
agra
47aaf3662a docs(ffi-linkage): checkpoint — Phase 5.0 global path routed (Part B started) 2026-06-14 20:32:48 +03:00
agra
e5ddfbe09a refactor(ffi-linkage): Phase 5.0 — route #foreign global decl onto extern AST
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).
2026-06-14 20:31:50 +03:00
agra
9ad04e2dda docs(ffi-linkage): note -Dupdate-goldens churn resolved (1-byte conform) 2026-06-14 16:17:26 +03:00
agra
0d39a1e168 test(ffi-linkage): conform 5 empty-stderr goldens to canonical 1-byte form
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.
2026-06-14 16:16:58 +03:00
agra
fde767913b docs(ffi-linkage): ratify decision 5 (Runtime*Class*); decision 6 stays open 2026-06-14 16:10:01 +03:00
agra
aafcbf6d78 docs(ffi-linkage): checkpoint — Phase 4 complete, Part A done, A→B gate locked 2026-06-14 16:08:01 +03:00
agra
422c6577cf feat(ffi-linkage): reject extern+export on one decl (Phase 4) — 1175 green 2026-06-14 16:06:22 +03:00
agra
847a027fb1 test(ffi-linkage): xfail extern+export mutual-exclusion diagnostic (Phase 4) 2026-06-14 15:46:33 +03:00
agra
a8e0a8961b docs(ffi-linkage): document extern/export linkage keywords (Phase 4) 2026-06-14 15:40:37 +03:00
agra
4101cbc3e7 feat(ffi-linkage): reject #foreign + postfix extern/export combo (Phase 4) — 1174 green 2026-06-14 15:39:27 +03:00
agra
dd927c2e94 test(ffi-linkage): xfail #foreign+postfix conflict diagnostic (Phase 4) 2026-06-14 15:37:25 +03:00
agra
5d4a2c26c1 test(ffi-linkage): GATE A→B — #foreign ≡ extern IR for fn/global/class (Phase 4) 2026-06-14 15:31:09 +03:00
agra
d4f683f525 docs(ffi-linkage): checkpoint — Phase 3 complete (aggregate extern/export) 2026-06-14 15:14:09 +03:00
agra
91d70bd864 test(ffi-linkage): lock postfix extern (jni) + export (objc defined) aggregates (Phase 3.1) 2026-06-14 15:13:23 +03:00
agra
a9a6d53dc0 feat(ffi-linkage): postfix extern/export on #objc_class aggregate (Phase 3.1) — 1348 green 2026-06-14 15:08:18 +03:00
agra
9f1d7be105 docs(ffi-linkage): checkpoint — Phase 3.0 xfail logged 2026-06-14 15:01:19 +03:00
agra
0bde545f24 test(ffi-linkage): xfail postfix extern on #objc_class aggregate (Phase 3.0) 2026-06-14 15:00:37 +03:00
agra
5ba8d302c2 docs(ffi-linkage): checkpoint — Phase 2 (export) complete + JIT spike findings 2026-06-14 14:55:15 +03:00
agra
23feea6a0c feat(ffi-linkage): consume export "csym" rename (Phase 2.2) — 1227 green
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.
2026-06-14 14:53:37 +03:00
agra
66d9169e59 test(ffi-linkage): xfail export "csym" rename (Phase 2.2)
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.
2026-06-14 14:48:35 +03:00
agra
a47ef20ad3 feat(ffi-linkage): lower export fns (Phase 2.1) — example 1226 green
`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.
2026-06-14 14:45:16 +03:00
agra
6a539ca057 test(ffi-linkage): xfail export fn called from C via AOT (Phase 2.0)
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.
2026-06-14 14:41:33 +03:00
agra
6932426c41 feat(ffi-linkage): lower extern data globals (Phase 1.2d) — Phase 1 complete
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.
2026-06-14 13:39:05 +03:00
agra
235f74a8c9 test(ffi-linkage): xfail example for extern data global (Phase 1.2c)
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.
2026-06-14 13:33:50 +03:00
agra
5777ff62ad feat(ffi-linkage): consume extern LIB "csym" rename for fns (Phase 1.2b)
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.
2026-06-14 13:30:59 +03:00
agra
5f946a3d44 test(ffi-linkage): xfail example for extern fn rename (Phase 1.2a)
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.
2026-06-14 13:26:57 +03:00
agra
18c43984e1 feat(ffi-linkage): lower extern fns as C imports (Phase 1.1)
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).
2026-06-14 13:17:00 +03:00
agra
78e304f552 test(ffi-linkage): xfail example for extern fn binding (Phase 1.0b)
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).
2026-06-14 13:06:54 +03:00
agra
df6b675e67 feat(ffi-linkage): fn-path accepts postfix extern/export + lib/name fields (Phase 1.0a)
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.
2026-06-14 13:02:42 +03:00
agra
62a3b46f6e feat(ffi-linkage): extern/export parser+AST plumbing, unconsumed (Phase 0.1)
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.
2026-06-14 12:48:56 +03:00
agra
bf6ef8370f feat(ffi-linkage): add kw_extern/kw_export tokens (Phase 0.0)
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.
2026-06-14 12:40:35 +03:00
agra
78f7bb7857 ... 2026-06-14 12:21:37 +03:00
agra
c562fe236d docs(plans): inline-asm design + ASM and FFI-linkage plans/checkpoints
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.
2026-06-14 12:16:10 +03:00
agra
e386a0d0b4 fix: reject direct assignment to a tagged-union variant member
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.
2026-06-13 21:18:40 +03:00
agra
4d32a4d4fb fix: propagate union-member type to a struct-literal RHS
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.
2026-06-13 18:55:41 +03:00
agra
8c47268539 fix: xx pack[i] to a protocol target heap-copies the element
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.
2026-06-13 18:55:10 +03:00
agra
d3f5cb20cb fix: visibility-aware type resolution for protocol method signatures
`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.
2026-06-13 15:44:11 +03:00
agra
45befed698 docs(issues): correct 0132 root cause; file 0133 and 0134
- 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.
2026-06-13 13:41:30 +03:00
agra
f13f4abfb1 fix: visibility-aware type-name resolution at registration time
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).
2026-06-13 13:41:11 +03:00
agra
ab3c9202ff test: run example corpus in zig build test; sx ir → stdout
`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.
2026-06-13 09:41:56 +03:00
525 changed files with 14883 additions and 1740 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.vsix filter=lfs diff=lfs merge=lfs -text

3
.gitignore vendored
View File

@@ -3,5 +3,4 @@ zig-out
.DS_Store
.vscode/
.sx-cache
.sx-tmp
current/
.sx-tmp

View File

@@ -405,7 +405,10 @@ any can be advanced independently.
After any code change:
```sh
zig build # must compile
zig build test # must pass
zig build test # must pass — runs the Zig unit tests
# AND the full examples/ + issues/
# regression corpus (a failing example
# fails the build)
```
After completing a phase's final step, run the phase's end-to-end verification command listed in `current/PLAN.md`.
@@ -415,9 +418,25 @@ After completing a phase's final step, run the phase's end-to-end verification c
After any compiler change:
1. **Build**: `zig build && zig build test`
2. **Run regression tests**: `bash tests/run_examples.sh`
- Every test must show `ok` (currently 324)
- Zero failures, zero timeouts
- `zig build test` runs the unit tests **and** the example/issue corpus as
one suite — a failing example fails the build. The corpus is driven by a
pure-Zig test (`src/corpus_run.test.zig`) that spawns the installed `sx`
binary per example (subprocess-isolated, with a per-run timeout), so no
shell script is involved.
2. **Regenerate snapshots**: `zig build test -Dupdate-goldens`
- Flips the corpus test to write each example's expected
`.exit`/`.stdout`/`.stderr` (+ `.ir` where one already exists) from
freshly-normalized output instead of asserting against it. This is the
preferred way to update snapshots — no shell script needed.
- A test is still keyed off its `expected/<name>.exit` marker, so seed an
empty marker first for a brand-new example (see "Adding a feature").
`zig build test` is the only way to run the corpus — there is no standalone
shell runner (the legacy `tests/run_examples.sh` was removed). Per-example
build/run directives live in an optional `expected/<name>.build` **JSON** sidecar
(see "Test layout" below): `{ "aot": true }` switches an example from JIT `sx run`
to a `sx build` + execute flow (needed to exercise a C-ABI symbol exported FROM sx
— a JIT-resident symbol is invisible to a dlopen'd C dylib); `{ "target":
"x86_64-linux" }` threads `--target` and arch-gates the example.
### Test layout
@@ -436,6 +455,7 @@ split into three streams (no more merged `2>&1`) plus an optional IR snapshot:
<root>/expected/XXXX-category-name.stdout # normalized stdout
<root>/expected/XXXX-category-name.stderr # normalized stderr
<root>/expected/XXXX-category-name.ir # optional `sx ir` snapshot
<root>/expected/XXXX-category-name.build # optional JSON build/run directives
```
A test is any `<name>.sx` with an `expected/<name>.exit` marker. The runner
@@ -443,14 +463,28 @@ scans two roots: `examples/` (the feature suite) and `issues/` (pinned bug
repros). Multi-file tests keep companions (`.c`/`.h`, imported `.sx`, fixture
dirs) under the same `XXXX-` prefix.
The optional `<name>.build` JSON sidecar carries per-example directives
(unknown keys are a hard error — never silently ignored):
- `"aot": true` — build a native binary and execute it instead of JIT `sx run`.
- `"target": "<triple|shorthand>"` — thread `--target` into every `sx`
invocation and gate on the host. If the target's arch+os **match** the host,
the example runs normally; if they **mismatch** (e.g. `x86_64-linux` on an
aarch64 host), the runner switches to **ir-only** mode — it skips
run/build/exec and asserts only `.exit` + `.ir` + `.stderr` from
`sx ir --target` (`.stdout` is not asserted). An `.ir` snapshot is **required**
in ir-only mode (its absence is a loud failure). This is how arch-pinned
examples (e.g. x86_64 inline-asm) are tested on a non-matching dev host while
still running end-to-end on a matching CI runner.
### Snapshot integrity
**Never run `--update` while tests are failing.** The `--update` flag blindly overwrites expected output with whatever the compiler produces — including error messages. If you update snapshots during a broken state, the test suite will "pass" against garbage output and real regressions become invisible.
**Never regenerate snapshots while tests are failing.** `-Dupdate-goldens` (and the legacy `--update`) blindly overwrite expected output with whatever the compiler produces — including error messages. If you regenerate during a broken state, the test suite will "pass" against garbage output and real regressions become invisible.
Safe workflow:
1. Fix the code until `bash tests/run_examples.sh` passes against the **existing** snapshots.
2. Only run `--update` when you've intentionally changed output (new feature, new test, changed formatting).
3. After `--update`, review the diff (`git diff examples/expected/ issues/expected/`) to confirm no error messages or empty output were captured.
1. Fix the code until `zig build test` passes against the **existing** snapshots.
2. Only run `zig build test -Dupdate-goldens` when you've intentionally changed output (new feature, new test, changed formatting).
3. After regenerating, review the diff (`git diff examples/expected/ issues/expected/`) to confirm no error messages or empty output were captured.
### Adding a new language feature
@@ -461,19 +495,19 @@ There is no monolithic smoke file — each feature is its own focused example.
2. Run it: `./zig-out/bin/sx run examples/XXXX-<category>-<name>.sx`
3. Seed the marker and capture expected output:
`: > examples/expected/XXXX-<category>-<name>.exit` then
`bash tests/run_examples.sh --update`
4. Verify all tests still pass: `bash tests/run_examples.sh`
`zig build test -Dupdate-goldens`
4. Verify all tests still pass: `zig build test`
### Test file roles
| File | Purpose |
|------|---------|
| `examples/XXXX-category-name.sx` | Focused feature example — one feature per file. |
| `examples/expected/XXXX-category-name.{exit,stdout,stderr}` | Expected exit code + the two output streams. Regenerate with `--update`. |
| `examples/expected/XXXX-category-name.{exit,stdout,stderr}` | Expected exit code + the two output streams. Regenerate with `zig build test -Dupdate-goldens`. |
| `examples/expected/XXXX-category-name.ir` | Optional `sx ir` snapshot — present only where lowering shape is locked. |
| `issues/NNNN-slug.md` | Open-issue / bug-report writeup (mark RESOLVED in a banner when fixed; the `.md` stays). |
| `issues/NNNN-slug.sx` (+ `issues/NNNN-slug/`) | The issue's minimal repro, co-located with the `.md`. A repro with an `issues/expected/NNNN-slug.exit` marker runs in the suite; unpinned ones don't. |
| `tests/run_examples.sh` | Test runner. Scans `examples/` and `issues/`; compares stdout/stderr/exit (+ optional IR) per test. |
| `src/corpus_run.test.zig` | The corpus runner inside `zig build test` — spawns `sx` per example, diffs stdout/stderr/exit (+ optional IR); regenerates snapshots under `-Dupdate-goldens`. |
### Unit test file convention
@@ -496,8 +530,8 @@ All Zig unit tests live in separate `*.test.zig` files alongside the source they
open bug, `issues/NNNN-slug.{md,sx}` (repro co-located with the writeup).
2. Run it: `./zig-out/bin/sx run <path>.sx`
3. Seed the marker (`: > <root>/expected/<name>.exit`) and capture expected:
`bash tests/run_examples.sh --update`
4. Verify: `bash tests/run_examples.sh`
`zig build test -Dupdate-goldens`
4. Verify: `zig build test`
### Resolving an open issue
@@ -505,8 +539,8 @@ When a bug filed under `issues/NNNN-slug.{md,sx}` is fixed:
1. Move the repro into the feature suite as a regression test:
`git mv issues/NNNN-slug.sx examples/XXXX-<category>-<name>.sx`.
2. Seed `examples/expected/XXXX-<category>-<name>.exit`, capture with `--update`,
and review the diff.
2. Seed `examples/expected/XXXX-<category>-<name>.exit`, capture with
`zig build test -Dupdate-goldens`, and review the diff.
3. Tighten the example's comment header to describe the feature (keep a one-line
`Regression (issue NNNN)` note for provenance).
4. Mark `issues/NNNN-slug.md` RESOLVED with a short banner (root cause + fix +
@@ -536,7 +570,7 @@ The compiler shrinks to: parse → IR → codegen → link → invoke a sx
function. Codesigning / Info.plist / AndroidManifest / javac / d8 /
aapt2 / zipalign / apksigner / framework embed / entitlements / asset
trees all run in the IR interpreter post-link via libc / process.run
foreign calls.
extern calls.
| File | Role |
|------|------|
@@ -546,7 +580,7 @@ foreign calls.
| [library/modules/build.sx](library/modules/build.sx) | `BuildOptions` setters + accessors. Adding a new bundling parameter = add a setter here + a hook in compiler_hooks.zig. |
| [library/modules/platform/android.sx](library/modules/platform/android.sx) | `AndroidPlatform` (state-on-struct, no module globals). `sx_android_*` helpers take `plat: *AndroidPlatform` as first arg. `logical_w` field drives `dpi_scale = pixel_w / logical_w` so consumer's design-width fits any physical resolution. |
| [src/ir/compiler_hooks.zig](src/ir/compiler_hooks.zig) | `BuildConfig` + every `BuildOptions.*` hook. Hook registry is in `Registry.registerDefaults`. |
| [src/ir/host_ffi.zig](src/ir/host_ffi.zig) | `dlsym(RTLD_DEFAULT)` + arity-switched cdecl trampolines. Lets `#foreign("c")` decls resolve at `#run` / post-link time against host libc. |
| [src/ir/host_ffi.zig](src/ir/host_ffi.zig) | `dlsym(RTLD_DEFAULT)` + arity-switched cdecl trampolines. Lets `extern "c"` decls resolve at `#run` / post-link time against host libc. |
| [src/main.zig](src/main.zig) | After `target.link()`, threads target_triple + frameworks + jni_main emissions into BuildConfig, then invokes the post-link callback by FuncId (or by `<module>.bundle_main` name). `--bundle` / `--apk` flags feed `bundle_path`; auto-fallback to `post_link_module = "platform.bundle"` when bundle_path is set without a registered callback. |
Specifics in [specs.md §10.5](specs.md). The full bundling pipeline

View File

@@ -193,28 +193,49 @@ pub fn build(b: *std.Build) void {
run_cmd.addArgs(args);
}
// Corpus paths for the LSP corpus-sweep test (src/lsp/corpus_sweep.test.zig).
// Inject absolute corpus dirs at configure time so the in-process analyzer
// sweep is CWD-independent; the test still ENUMERATES the directory
// contents at runtime (new examples are covered with no test edit).
// Corpus paths for the corpus tests (src/lsp/corpus_sweep.test.zig — the
// in-process analyzer sweep — and src/corpus_run.test.zig — the end-to-end
// example/issue runner). Inject absolute corpus dirs + the installed `sx`
// binary path at configure time so the tests are CWD-independent; the
// runner still ENUMERATES the directory contents at runtime, so new
// examples are covered with no test edit.
const corpus_opts = b.addOptions();
corpus_opts.addOption([]const u8, "examples_dir", b.path("examples").getPath(b));
corpus_opts.addOption([]const u8, "issues_dir", b.path("issues").getPath(b));
corpus_opts.addOption([]const u8, "library_dir", b.path("library").getPath(b));
// Absolute path to the installed `sx` binary the corpus runner spawns per
// example. The runner test depends on the install step (below) so this
// exists — and so the sibling library/ tree the binary loads is in place.
corpus_opts.addOption([]const u8, "sx_exe", b.getInstallPath(.bin, "sx"));
// `zig build test -Dupdate-goldens` flips src/corpus_run.test.zig from
// verify mode to regenerate mode: it overwrites each example's expected
// .exit/.stdout/.stderr (+ .ir where one exists) with freshly-normalized
// output instead of asserting against it. The in-build equivalent of the
// legacy `run_examples.sh --update`.
const update_goldens = b.option(
bool,
"update-goldens",
"Regenerate example/issue snapshots instead of verifying them (use with `zig build test`)",
) orelse false;
corpus_opts.addOption(bool, "update_goldens", update_goldens);
mod.addOptions("corpus_paths", corpus_opts);
const mod_tests = b.addTest(.{
.root_module = mod,
});
const run_mod_tests = b.addRunArtifact(mod_tests);
// src/corpus_run.test.zig spawns the installed `sx` binary per example, so
// the mod test binary must not run until `zig-out/bin/sx` + `zig-out/library`
// are installed. This is what folds the full example/issue regression suite
// into `zig build test` — no shell script, just a Zig test.
run_mod_tests.step.dependOn(b.getInstallStep());
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests");
const test_step = b.step("test", "Run unit tests + the example/issue regression suite");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
}

394
current/CHECKPOINT-ASM.md Normal file
View File

@@ -0,0 +1,394 @@
# sx Inline Assembly — Checkpoint (ASM stream)
Companion to `current/PLAN-ASM.md`; design in
[design/inline-asm-design.md](../design/inline-asm-design.md). Update after every
commit, one step at a time per the cadence rule (no commit may both add a test
and make it pass).
## Last completed step
**G (indirect-memory `=*m` place outputs)** — the LAST substantive asm feature.
Unlike a write-through `=` output (which returns a value then stored), an
indirect output passes the place ADDRESS to the asm and the asm writes through
it — no return slot. `emitInlineAsm` (`src/backend/llvm/ops.zig`): indirect
outputs are excluded from the LLVM return type; their pointer is an opaque `ptr`
call arg placed **first** (arg-consuming constraint order = output-section
indirect pointers → inputs → read-write tied seeds); each gets an
`elementtype(T)` call-site attribute (required in the opaque-pointer era) via
`LLVMCreateTypeAttribute`/`LLVMAddCallSiteAttribute`; the store-back loop skips
them. New `asmIsIndirect(e, op)` helper. Lowering (`lowerAsmExpr`) stops
rejecting `*` (constraint kept verbatim, `=*m` reaches the constraint string
as-is). `asmOperandIndex` unchanged — indirect outputs still count as operands,
so `%[name]``${N}` holds. Verified by **running** on aarch64: store-through-
pointer (`str x9, %[out]` → 42, IR `"=*m,~{x9}"(ptr elementtype(i64) …)`) and a
mixed case (indirect + value output + input → `"=*m,=r,r"`, indirect ptr arg
first, `${0}/${1}/${2}` correct). Two commits per cadence: (1)
`examples/1652-platform-asm-indirect-mem.sx` locked the rejection; (2) implemented
+ flipped 1652 to a runnable aarch64-pinned example (`{ "target": "macos" }`,
ir-only elsewhere). `zig build test` green (661 corpus, 446 unit). Files:
`src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`, `examples/1652-*`.
Prior: **G (read-write `+` place outputs)** — a `+r` / `+{reg}` `-> @place` output is now
implemented. LLVM has no `+` constraint, so a
read-write place lowers to: an output **`=`** constraint (return slot, stored back
through the place after the call; the leading `+` rewritten to `=` in
`appendAsmConstraints`), **plus** a **tied input** (the decimal index of that
output) appended **after** the regular inputs, seeded with the place's loaded
value passed as a call arg. Tied inputs come **last** so existing operand indices
(`%[name]``${N}`) are undisturbed — `asmOperandIndex` unchanged. Lowering
(`lowerAsmExpr`) no longer rejects `+` (indirect `*` still rejected loudly).
`emitInlineAsm` (`src/backend/llvm/ops.zig`): grows arg/param arrays by the rw
count (`n_args = n_inputs + n_rw`), loads each seed (`asm.rw.seed`), emits the
tied constraint, and the existing store-back path writes the modified output back.
New `asmIsReadWrite(e, op)` helper. Verified by **running**: increment-in-place
(41→42, IR `"=r,0"`) and a mixed case (rw place + regular input + value output) →
textbook `"=r,=r,r,0"` with correct `${N}` indices and args `(input, seed)`. Two
commits per cadence: (1) `examples/1650-platform-asm-rw-place.sx` locked the
rejection; (2) implemented + flipped 1650 to a runnable aarch64-pinned example
(`{ "target": "macos" }`, ir-only elsewhere). `zig build test` green (658 corpus,
446 unit). Files: `src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`,
`examples/1650-*`.
Prior: **2**`-> @place` write-through outputs. An asm result can be **stored through
a place** (local / struct field) instead of returned; the place output does NOT
join the result tuple. Parser: `-> @place` parses the `@place` as an ordinary
address-of expression → an `out_place` operand (`src/parser.zig`). Lowering
(`lowerAsmExpr`): out_place operand = the lowered `@place` address, `out_ty` =
the pointee; read-write (`+`) and indirect-memory (`*`) constraints rejected
loudly (not yet implemented). Added `out_ty: TypeId` to the IR `AsmOperand`
(`src/ir/inst.zig`) so emit builds the **combined** return struct (ALL outputs).
`emitInlineAsm` rewrite (`src/backend/llvm/ops.zig`): the LLVM return type is now
built from every output's `out_ty`; after the call, out_place slots are
`store`d through their address and out_value slots rebuild the sx result — with a
**fast path** (no place outputs → the asm's struct return IS the result, so
pure-value asm IR is unchanged). Verified: write-to-local (`get42`→42), struct
field (`@p.b`), mixed value+place (`v=10 b=20`), `+` rejected. Locked with
`examples/1649-platform-asm-place-output.sx` (mixed, runs on aarch64). `zig build
test` green (657 corpus, 446 unit). Files: `src/parser.zig`, `src/ir/inst.zig`,
`src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`, `examples/1649-*`.
Prior: **F** — global (module-scope) asm. A top-level `asm { "tmpl", };` block (template
only) lowers to LLVM `module asm`, and a lib-less `extern` calls into the symbols
it defines. New `asm_global` AST node (`src/ast.zig`) + `parseAsmGlobal`
(`src/parser.zig`, dispatched from `parseTopLevel` on `kw_asm`) — rejects
`volatile` and any operands/clobbers. The node forced (and got) arms in the same
three `Node.Data` switches as `asm_expr` (`sema.zig` ×2, `semantic_diagnostics.zig`).
`Module` gains a `global_asm: ArrayList([]const u8)` (`src/ir/module.zig`);
`lowerMainAndComptime` captures each template (the dead `lowerDecls` is NOT the
top-level pass — `lowerRoot` Pass 2 uses `lowerMainAndComptime`); `emit_llvm.zig`'s
`emit()` appends each via `LLVMAppendModuleInlineAsm` (source order). Verified
end-to-end: an aarch64 `_my_add` global routine called via `extern` returns 42.
Locked with `examples/1648-platform-asm-global.sx`
(`.build { "aot": true, "target": "macos" }` → AOT build+run on aarch64, ir-only
elsewhere). `zig build test` green (656 corpus, 446 unit). **(Correction, later:
module asm ALSO runs under the JIT — `sx run` compiles to an in-memory object,
the integrated assembler assembles the `module asm` into it, ORC relocates and
runs it, so the symbol is resolvable at JIT main execution. The original "AOT
only" note was wrong; see 1653 for the JIT sibling. The genuine boundary is a
COMPILE-TIME `#run` call into a module-asm symbol, which fails loud via host
dlsym-miss — see 1654.)** Files: `src/ast.zig`, `src/parser.zig`, `src/sema.zig`,
`src/ir/semantic_diagnostics.zig`, `src/ir/module.zig`, `src/ir/lower/decl.zig`,
`src/ir/emit_llvm.zig`, `examples/1648-*`.
Prior: **E** — multi-output tuples. **Inline asm now returns tuples.** Replaced the
N>1 bail with a shared `asmResultType` helper (`src/ir/lower/expr.zig`, mixed
into `Lowering`) that derives the result type from the `out_value` operands
(0→void, 1→T, N→named tuple, named via the §II.5 effective-name rule). The key
realization: `toLLVMType(tuple)` already produces a literal struct `{T1,…,Tn}`
exactly LLVM's multi-output asm return — so **emit needed NO change**; building
the op with a tuple result type makes the asm call return the struct, which IS
sx's tuple value (destructured by the normal `tuple_get` path). `inferType`'s
`.asm_expr` arm now also delegates to `asmResultType` (single owner), so
`return asm`, `x := asm`, and `q, r := asm` all agree on the type. Verified
end-to-end on aarch64: `split(0x1234)``(lo=52, hi=18)`, a udiv/msub divmod→
`(3, 2)`. IR is textbook: `call { i64, i64 } asm "divq ${4}",
"={rax},={rdx},{rax},{rdx},r,~{cc}"(…)` → extractvalue → tuple. Converted 1640 to
the x86_64 multi-output IR lock (ir-only) + added `1647-platform-asm-aarch64-multi`
(runs on aarch64). `zig build test` green (655 corpus, 446 unit). Files:
`src/ir/lower/expr.zig`, `src/ir/lower.zig`, `src/ir/expr_typer.zig`,
`examples/164{0,7}-*`.
Prior: **C.1 + D** — inline asm CODEGEN (lowering builds the op + LLVM emit). **Inline
assembly now runs end-to-end.** `lowerAsmExpr` (`src/ir/lower/expr.zig`) stops
bailing: it resolves each operand's effective name (§II.5 auto-naming), interns
template/constraints/clobbers, lowers input `Ref`s, derives the result `TypeId`
(0→void, 1→T), and builds the `inline_asm` op. Added a `%[name]`-references-a-
real-operand check (the last deferred validation). Multi-output (N>1) still bails
loudly ("Phase E"). `emitInlineAsm` (`src/backend/llvm/ops.zig`, port of Zig's
`airAssembly`): assembles the LLVM constraint string (outputs→inputs→`~{clobber}`,
`,``|`), rewrites the template (`%[name]``${N}`, `%%``%`, `$``$$`, `%=`
`${:uid}`), then `LLVMGetInlineAsm` + `LLVMBuildCall2` (AT&T). Dispatch wired
(`emit_llvm.zig`, replacing the C.0 `@panic`). **`llvm_shim.c`**: added
`LLVMInitializeNativeAsmParser()` — the JIT must assemble inline asm at run time.
Verified end-to-end: aarch64 `add`/`mov` run on the host (exit 42), `nop volatile`
runs (1642 now exit 0), IR is textbook (`call i64 asm "add ${0},${1},${2}",
"=r,r,r"(…)`). Locked with `examples/1645-platform-asm-aarch64-add.sx` (runs on
aarch64, ir-only elsewhere via `.build` + `.ir`). Also added the `inferType`
`.asm_expr` arm (`src/ir/expr_typer.zig`, 0→void / 1→T) — without it a bare
`x := asm {…-> T}` binding inferred `.unresolved` and silently produced 0;
regression-locked with `examples/1646-platform-asm-value-binding.sx`. Updated
1640 (now Phase-E bail) + 1642 (now runs). `zig build test` green (654 corpus,
446 unit). Files: `src/ir/lower/expr.zig`, `src/backend/llvm/ops.zig`,
`src/ir/emit_llvm.zig`, `src/ir/expr_typer.zig`, `llvm_shim.c`,
`examples/164{0,2,5,6}-*`.
Prior: **C.0** — IR op `inline_asm` (lock; no behavior change). Added `inline_asm:
InlineAsm` to the IR `Op` union + the `InlineAsm` struct (`template: StringId`,
`operands: []const AsmOperand` {role/name/constraint/operand}, `clobbers:
[]const StringId`, `has_side_effects`) in `src/ir/inst.zig` — all strings
interned, operands in source order, result on `Inst.ty`. The new variant forced
(and got) arms in two exhaustive `Op` switches: `src/ir/interp.zig` (loud
`bailDetail` — inline asm is never comptime-evaluable) and `src/ir/print.zig`
(IR dump). `src/ir/emit_llvm.zig` gets a `@panic` **tripwire** — emit lands in
Phase D, and until then `lowerAsmExpr` still bails so no `inline_asm` op is ever
created (reaching emit would be a lowering-switched-over-too-early bug). Unit
test `inline_asm op shape` in `src/ir/inst.test.zig`. `zig build test` green
(652 corpus, 446 unit). Files: `src/ir/inst.zig`, `src/ir/interp.zig`,
`src/ir/print.zig`, `src/ir/emit_llvm.zig`, `src/ir/inst.test.zig`.
Prior: **B.1** — operand-name validation (design §II.5 auto-naming rule). Extended
`lowerAsmExpr` with a `pinnedRegister(constraint)` helper (`"={eax}"``eax`,
`"+{rax}"``rax`, `"=r"`→null) and two checks: (1) **reject the echo form**
`[eax] "={eax}"` — a label identical to its own pinned register is redundant
(the operand is already auto-named after the register); (2) **reject duplicate
operand names** (ambiguous `%[name]` / result field). Locked with
`examples/1643-platform-asm-echo-name.sx` + `1644-platform-asm-duplicate-name.sx`.
`zig build test` green (652 corpus, 0 failed; 445 unit). Files:
`src/ir/lower/expr.zig`.
Prior: **B.0** — asm shape validation (compile-path diagnostics). Restructured the
`.asm_expr` lowering arm into `lowerAsmExpr` (`src/ir/lower/expr.zig`, mixed into
`Lowering` in `src/ir/lower.zig`): it validates BEFORE the not-yet-implemented
codegen bail, so the user sees the real problem first. Two checklist items now
enforced with named diagnostics: (1) **template must be a compile-time-known
string** (`"..."` / `#string`); (2) **no value outputs ⇒ must be `volatile`**
(mirrors Zig — a result-less asm could be deleted). Valid shapes still bail with
the "codegen not yet implemented" message. Result-type derivation + auto-naming
stay deferred to a later step (observable only once Phase C produces a real IR
op). Locked with `examples/1641-platform-asm-missing-volatile.sx` (volatile
error) + `1642-platform-asm-nop-volatile.sx` (volatile no-output accepted →
codegen bail). `zig build test` green (650 corpus, 0 failed; 445 unit). Files:
`src/ir/lower/expr.zig`, `src/ir/lower.zig`, `examples/164{1,2}-*`.
Prior: **A.1** — parse `asm { … }` + loud lowering bail (folded A.1+A.2 into one honest
lock commit, since the loud bail IS current correct behavior — cadence option
(a)). Added `AsmExpr`/`AsmOperand` to `src/ast.zig` + the `asm_expr` `Node.Data`
arm; `parseAsmExpr` in `src/parser.zig` (`parsePrimary` `.kw_asm` dispatch) —
parses the template, flat operand list (`[name]? "constraint" -> Type` value
output / `= expr` input), and `clobbers(.…)`; `volatile`/`clobbers` recognized
contextually via `isContextualWord`. The new `asm_expr` tag forced (and got)
arms in three exhaustive `Node.Data` switches: `src/sema.zig` `analyzeNode` +
`findNodeAtOffset`, `src/ir/semantic_diagnostics.zig` `checkBindingNames` (all
recurse into template + operand payloads). Lowering bails LOUD + named in
`src/ir/lower/expr.zig` ("inline assembly codegen is not yet implemented…") via
an explicit `.asm_expr` arm (not the generic `unknown_expr` else) returning
`emitPlaceholder`. `-> @place` write-through is rejected with a clear "Phase 2"
parse error. Locked with `examples/1640-platform-asm-parse.sx` (multi-output
`divmod`, named operands, register pins, clobbers — parses then bails; called
from `main`). `zig build test` green (648 corpus, 0 failed; 445 unit). Files:
`src/ast.zig`, `src/parser.zig`, `src/sema.zig`, `src/ir/semantic_diagnostics.zig`,
`src/ir/lower/expr.zig`, `examples/1640-*`.
Prior: **A.0**`kw_asm` keyword (first compiler code). Added the `kw_asm` `Token.Tag`
variant + `.{ "asm", .kw_asm }` keyword-map entry in `src/token.zig`; `volatile` /
`clobbers` deliberately stay OUT of the global table (contextual). New exhaustive
`Tag` switch in `src/lsp/server.zig` `classifyToken` flagged the missing arm (the
intended coverage tripwire) — added `.kw_asm` to the keyword group. Lock test in
new `src/lexer.test.zig` (`asm``kw_asm`, `volatile`/`clobbers``identifier`),
wired into the `src/root.zig` barrel as `lexer_tests`. `zig build test` green (648
corpus, 0 failed; 445 unit, 0 failed — +1). Files: `src/token.zig`,
`src/lexer.test.zig`, `src/root.zig`, `src/lsp/server.zig`.
Prior: **0.2** — CLAUDE.md docs for `<name>.build`; **Phase 0 COMPLETE**.
**0.1** — corpus runner **ir-only branch** for cross-target examples. Replaced
0.0's loud placeholder bail: when `cfg.target` doesn't match the host (`ir_only`),
`sweepRoot` skips run/build/exec and verifies via `sx ir --target` only —
asserting `.exit` (ir cmd) + `.ir` (normalized stdout) + `.stderr`, never
`.stdout` (write skipped in update mode, assertion skipped in verify mode). An
`.ir` snapshot is **required** in ir-only mode — its absence is a loud failure
("needs an .ir snapshot for ir-only mode"). Locked with
`examples/1639-platform-target-cross.sx` (asm-free `main :: () -> i64 { return 0;
}`), `.build` `{ "target": "x86_64-linux" }`, + checked-in `.ir`. Verified both
guards fire: corrupting the `.ir` → IR mismatch; deleting it → the require-failure.
`zig build test` green (647 corpus, 0 failed; 444 unit). Files:
`src/corpus_run.test.zig`, `examples/1639-*`.
## Current state
**Inline assembly works end-to-end: 0, 1, and N value outputs (tuples).** Full
pipeline: lex (A.0) → parse (A.1) → validate (B.0/B.1 + `%[name]` check) → IR op
(C.0) → lower-builds-op + LLVM emit + JIT asm-parser init (C.1/D) → multi-output
tuples (E). Register-class + register-pinned operands, inputs, **symbol operands
(`"s"` → direct `bl`/`call` to a function/global by mangled name)**, clobbers,
`#string` multi-instruction templates, `%[name]`/`%%` rewriting, and the §II.5
auto-naming rule all work and execute on the host JIT. Global `asm { … }` (Phase F) works via
lib-less `extern` under BOTH the JIT (`sx run` → 1653) and AOT (1648) — `sx run`
compiles to an object, so the integrated assembler bakes the `module asm` symbol
in and ORC resolves it. All three `-> @place` output forms now work and execute
on aarch64: **write-through** `=` (Phase 2), **read-write** `+` (tied input), and
**indirect-memory** `=*m` (pointer arg + `elementtype`, asm writes through it).
**Inline assembly is now feature-complete — no substantive features remain.** The
x86_64 syscall-write ir-only example is DONE (1651). Global asm runs under both
JIT (1653) and AOT (1648). `readme.md` now has an "Inline Assembly" section.
Known orthogonal bug: **issue 0137**`sx run` on a program with no `main`
segfaults (`src/target.zig:256-273`, unguarded JIT entry lookup). Pre-existing,
asm-independent; does NOT block the ASM stream (every example has a `main`).
Phase EF feasibility already confirmed against the live tree
(`LLVMGetInlineAsm` / `LLVMBuildCall2` / `LLVMAppendModuleInlineAsm` in LLVM@19
`Core.h`; ERR-stream `extractvalue`→tuple in `emit_llvm.zig:726-927`; lib-less
`extern`, 60 sites; `--target` a global CLI flag).
## Next step
**Inline assembly is feature-complete.** All substantive features are done:
0/1/N value outputs (tuples), register-class + pinned operands, inputs, clobbers,
`#string` templates, `%[name]`/`%%`/`$`/`%=` rewriting, §II.5 auto-naming, global
`asm { … }` (AOT), and all three `-> @place` output forms — write-through (`=`),
read-write (`+`), and indirect-memory (`=*m`). The x86_64 syscall-write ir-only
example (1651) and the output-to-`const` rejection (issue 0138) are also done.
Global asm runs under BOTH the JIT (`sx run` → object → ORC; 1653) and AOT (1648)
— the earlier "AOT only / `sx run` mishandles module-asm" note was stale and has
been corrected. The one genuine boundary is a COMPILE-TIME `#run` into a
module-asm symbol: the interpreter resolves externs via host dlsym, the symbol
isn't linked yet, so it already fails loud (`comptime extern call: symbol not
found via dlsym`) — pinned by 1654.
Remaining work, all **polish** (optional):
- None substantive. Possible niceties: tighten the `#run`-into-module-asm error
text to name module-asm specifically; broaden clobber validation to a checked
per-arch enum (design doc Phase 4).
Orthogonal: **issue 0137** (no-`main` JIT segfault).
Done since last: output-to-`const` rejection (issue 0138), x86_64 syscall-write
ir-only example (1651).
Orthogonal: **issue 0137** (no-`main` segfault).
## Log
- (init) Plan + design doc written; ASM stream opened.
- (0.0) Corpus runner target-gating: `<name>.build` JSON config (replaces `.aot`
marker), `--target` threading, `hostMatchesTarget` execute-gate, loud
cross-target placeholder bail. Migrated 1226/1227 `.aot``.build`; locked with
1638 fixture + unit tests. `zig build test` green.
- (0.1) ir-only branch: cross-target examples verify via `sx ir --target` only
(exit+ir+stderr, no stdout; `.ir` required). Locked with 1639 fixture; verified
corrupt-.ir → mismatch and missing-.ir → loud failure. `zig build test` green.
- (0.2) docs: CLAUDE.md documents `<name>.build` JSON sidecar (aot + target +
ir-only gating), replacing stale `.aot` marker prose. **Phase 0 COMPLETE.**
- (A.0) `kw_asm` keyword in token.zig (+ map entry); LSP `classifyToken` switch
coverage; lock test in new `lexer.test.zig` (wired via root.zig). `volatile` /
`clobbers` stay contextual identifiers. `zig build test` green (445 unit, +1).
- (A.1) parse `asm { … }``AsmExpr` + loud lowering bail; `asm_expr` arms in 3
exhaustive `Node.Data` switches; `-> @place` rejected (Phase 2). Adopted operand
auto-naming rule (design §II.5). Locked with 1640 fixture. Filed orthogonal
issue 0137 (no-`main` JIT segfault). `zig build test` green (648 corpus, 445 unit).
- (B.0) asm shape validation in `lowerAsmExpr`: comptime-string template +
no-output⇒volatile, with named diagnostics before the codegen bail. Locked with
1641 (volatile error) + 1642 (volatile accepted). `zig build test` green (650
corpus, 445 unit).
- (B.1) operand-name validation: `pinnedRegister` helper + reject echo form
(`[eax] "={eax}"`) and duplicate names. Locked with 1643 + 1644. `zig build
test` green (652 corpus, 445 unit).
- (C.0) IR op `inline_asm: InlineAsm` + interp `bailDetail` + print arm + emit
`@panic` tripwire (Phase D). No behavior change (lowering still bails). Unit
test `inline_asm op shape`. `zig build test` green (652 corpus, 446 unit).
- (C.1+D) CODEGEN — `lowerAsmExpr` builds the op (effective names, interned
strings, input Refs, 0/1 result type) + `%[name]` validation; `emitInlineAsm`
(constraint string + template rewrite + `LLVMGetInlineAsm`/`BuildCall2`, AT&T);
`inferType` arm; `LLVMInitializeNativeAsmParser` for the JIT. **Inline asm runs
end-to-end.** N>1 bails (Phase E). Locked with 1645 (aarch64 add, runs) + 1646
(`:=` binding); updated 1640/1642. `zig build test` green (654 corpus, 446 unit).
- (E) multi-output tuples — `asmResultType` helper (0→void/1→T/N→named tuple),
shared by lowering + `inferType`. `toLLVMType(tuple)` == LLVM multi-output
struct, so emit unchanged; the asm struct return IS the sx tuple. Runs on
aarch64 (1647: `split``(lo,hi)`); 1640 → x86 multi-output IR lock (ir-only).
`zig build test` green (655 corpus, 446 unit).
- (F) global asm — `asm_global` AST node + `parseAsmGlobal` (top-level, rejects
volatile/operands); `Module.global_asm` captured in `lowerMainAndComptime`;
`emit()` appends via `LLVMAppendModuleInlineAsm`; call-into via lib-less
`extern`. AOT-verified (1648, `_my_add`→42). `zig build test` green (656 corpus).
- (docs) readme.md "Inline Assembly" section (b8800a2).
- (2) `-> @place` write-through — `out_place` operand; `out_ty` on the IR
AsmOperand; `emitInlineAsm` builds the combined output struct + splits
(out_place → store-through, out_value → result), fast-path when no places.
`+`/`*` rejected. Locked with 1649 (mixed, runs). `zig build test` green (657
corpus, 446 unit).
- (G) read-write `+` place outputs — `+` lowers to an output `=` + a tied input
(output-index constraint) seeded with the place's loaded value, tied inputs
appended last (operand indices undisturbed). `appendAsmConstraints` rewrites
`+``=`; `emitInlineAsm` grows args by the rw count + loads seeds;
`asmIsReadWrite` helper. Lowering stops rejecting `+` (`*` still rejected). Two
commits (cadence): 1650 locked the rejection, then flipped to a runnable
aarch64 example (`"=r,0"` IR). `zig build test` green (658 corpus, 446 unit).
- (0138) output-to-`const` rejection — fixed the underlying general bug: scalar
`@const` (address-of a folded `::` constant) reinterpreted the value as a
pointer (`inttoptr`). `src/ir/lower/expr.zig` `.address_of` now diagnoses a
scalar const (local + module) instead of falling through; array/struct consts
keep storage. asm `-> @const` gets the clean diagnostic for free (same path).
Regression `examples/1177-diagnostics-addr-of-const-rejected.sx`. Issue 0138
RESOLVED. `zig build test` green (659 corpus, 446 unit).
- (x86 syscall) x86_64 Linux `write(2)` via raw `syscall` — locks the constraint
string `={rax},{rax},{rdi},{rsi},{rdx},~{rcx},~{r11},~{memory}` (register-pinned
inputs + pinned value output + pointer input + clobbers). ir-only on aarch64
(`.ir` asserted), runs on x86_64-linux (hand-authored `"ok\n"` stdout).
`examples/1651-platform-asm-x86-syscall-write.sx`. Pure additive lock, no
compiler change. `zig build test` green (660 corpus, 446 unit).
- (G indirect) indirect-memory `=*m` place outputs — the place address is passed
as an opaque `ptr` arg (with an `elementtype(T)` call-site attr), placed before
inputs; asm writes through it; no return slot; store-back skips it.
`asmIsIndirect` helper; lowering stops rejecting `*`. Verified by running on
aarch64 (store-through → 42; mixed indirect+value+input → `"=*m,=r,r"`). Two
commits (cadence): 1652 locked the rejection, then flipped to a runnable aarch64
example. **Inline asm now feature-complete.** `zig build test` green (661 corpus,
446 unit).
- (jit) explored "asm in JIT": found it ALREADY works — `sx run` emits an
in-memory object (integrated assembler bakes in both in-function inline asm and
`module asm`), then ORC relocates+runs it. The stale "AOT only / `sx run`
mishandles module-asm" checkpoint prose was corrected. Locked global-asm-under-
JIT with `examples/1653-platform-asm-global-jit.sx` (`{ "target": "macos" }`, no
aot, → 42). `zig build test` green (662 corpus, 446 unit).
- (comptime guard) pinned the one genuine module-asm boundary:
`examples/1654-platform-asm-global-comptime-call.sx``#run` into a module-asm
symbol fails loud (`comptime extern call: symbol not found via dlsym`) because
the interpreter resolves externs via host dlsym before link. Arch-independent
(no `.build`). `zig build test` green (663 corpus, 446 unit).
- (round trip) `examples/1655-platform-asm-callback-into-sx.sx` — global-asm
trampoline that `bl _cb` back into an `export`ed sx function (sx→asm→sx, → 42).
Documented that `export` (external linkage + C symbol + C ABI) is what makes
the callback resolvable; `callconv(.c)` alone leaves it `internal` (DCE'd).
`zig build test` green (664 corpus, 446 unit).
- (symbol ops) symbol operands (`"s"`) — feed a function/global symbol; the
template emits its platform-mangled name so `bl %[fn]` is a DIRECT branch (one
fewer indirection than register-indirect `blr`, portable — no hardcoded `_`).
Emit passes the operand with its own llvm type (LLVMTypeOf), no coercion
(`asmIsSymbol` helper); lowering lowers the function RHS to `ptr @fn`. Decided
AGAINST mirroring Zig (which has no symbol operand — 483 std asm sites, none
call a function) because the direct `bl` matters. Two commits (cadence): 1656
locked the rejection (replacing an LLVM-verifier crash), then implemented +
flipped to a runnable aarch64 example (objdump-confirmed direct `bl <_cb>`).
`zig build test` green (665 corpus, 446 unit).
- (x86 cross-arch) ir-only x86_64 siblings so each emit path is locked on BOTH
arches: 1657 read-write (`"incq ${0}","=r,0"`), 1658 indirect (`"movq $$42,
${0}","=*m"`(ptr elementtype)), 1659 symbol (`"call ${2:P}"`, direct call). x86
templates validated by cross-emitting an object (integrated assembler accepts;
objdump confirms 1659's direct `call` reloc). Pure additive locks. `zig build
test` green (668 corpus, 446 unit).
- (symbol portability) made `%[fn]` portable across arches — `renderAsmTemplate`
auto-injects LLVM's `:c` modifier (`${N}``${N:c}`) for symbol (`"s"`) operands
lacking an explicit modifier (`asmNamedIsSymbol` helper). Without it x86 renders
`$cb` (a bad `call` target needing a hand-written `:P`); aarch64 unaffected.
Verified `:c``:P` for x86-64 calls (both → `R_X86_64_PLT32`). Explicit
`%[fn:X]` still wins (escape hatch). 1659 dropped its `:P` → same plain `%[fn]`
as aarch64 1656; both IRs regen to `${N:c}`. `zig build test` green (668 corpus,
446 unit).
## Known issues
- **0138** — RESOLVED. `@const` (address-of a `::` comptime constant) yielded a
wild pointer (`inttoptr (i64 <value> to ptr)`). Fixed by diagnosing scalar
`@const` in `src/ir/lower/expr.zig` `.address_of` (no storage; array/struct
consts unaffected). Delivered the ASM "output-to-`const` rejection" for free.
Regression `examples/1177-diagnostics-addr-of-const-rejected.sx`.
- **0137** — `sx run` on a program with no `main` segfaults (unguarded JIT entry
lookup, `src/target.zig:256-273`). Pre-existing, asm-independent. Filed
`issues/0137-jit-run-no-main-segfault.md`. Does not block A.1.

View File

@@ -0,0 +1,861 @@
# sx `extern`/`export` + `#foreign` retirement — Checkpoint (FFI-linkage stream)
Companion to `current/PLAN-EXTERN-EXPORT.md` — one merged plan: **Part A** adds
`extern`/`export`, **Part B** migrates `#foreign` and purges `foreign`. Update after
every commit, one step at a time per the cadence rule.
## Last completed step
**Phase 9 COMPLETE — total `foreign` purge; 9.4 GATE PASSES.** **THE ENTIRE
FFI-LINKAGE STREAM (Parts A + B, Phases 09) IS DONE.** Final commits: 9.0 token
delete (`dfae690`), 9.3 src/docs/example/library comment purge (`811a280`, `e99383f`,
`dc51c4b`, + the capital-Foreign sweep), 9.3 example filename renames + dedup
(`b52d424`), 9.3/9.4 issues/*.md purge + GATE (`b9cfe25`).
- **9.0:** deleted the `hash_foreign` token entirely (token/lexer/parser/lsp + the lex
test); `#foreign` now → a generic "expected ';'" parse error (accepted UX cost);
deleted the obsolete 1176 rejection test.
- **9.1/9.2:** all internal identifiers renamed (linkage→`extern`/`is_extern`,
runtime-class→`Runtime*`/`runtime_*` per Decision 5, `foreign_path``runtime_path`
across the build-hook boundary); `foreign_expr` node eliminated.
- **9.3:** purged every `foreign` COMMENT (src caps + lowercase, examples, docs incl.
the obsolete inline-asm Deviation 6, editors/vscode grammar) + renamed all 10
`*-foreign*` example files (+ companions/expected/refs) to extern/runtime names
(dedup'd 1218↔1229, removed orphan 1620 dir) + rewrote 20 issues/*.md writeups +
renamed issues/0043.
- **9.4 GATE:** `grep -rniIE 'foreign' src/ library/ examples/ issues/ docs/ editors/
specs.md readme.md CLAUDE.md` → **0**, excluding only the legitimate keeps:
`SQLITE_CONSTRAINT_FOREIGNKEY` (SQLite API const) + vendored `library/vendors/sqlite/
c/*` (upstream third-party C). No `foreign`-named files in the tree (node_modules +
.sx-tmp are gitignored third-party/scratch). Suite green (644 corpus / 443 unit, 0
failed).
### Prior: Phase 9.3 — text/comment purge (src + docs + example comments) (commits
`e99383f` docs, `dc51c4b` src, + examples purge STAGED pending a classifier outage —
commit message ready; `git commit` the staged `examples/` changes when Bash is back).
`foreign` is now purged from: **all `src/` comments** (reworded to extern/runtime-class;
fixed 2 user-facing diagnostics — the type-annotation parse error no longer lists
`#foreign`, and the Android no-`#jni_main` help shows `#jni_class(…) extern`), **specs/
readme/CLAUDE** ("Foreign Function Interface"→"C Interop", etc.), and **all example .sx
comments** (1219 stdout labels Foreign→Extern, snapshot regenerated). Suite green
(646/444) throughout; snapshot-neutral except the intentional 1219 regen.
**What still contains `foreign` (the analyzed keep-list + the not-yet-done):**
- **KEEP (gate-exempt):** `src/` `hash_foreign` token + lexer entry + `lex hash_foreign`
test (`#foreignx`) + the 4 parser rejection messages ("`#foreign` has been removed…");
`1176-diagnostics-foreign-removed.sx` (its `#foreign` decl + comments ARE the rejection
test); `SQLITE_CONSTRAINT_FOREIGNKEY` + vendored `library/vendors/sqlite/c/*`.
- **NOT YET DONE:** example FILENAMES (`*-foreign*.sx` + the `0729`/`1205`/`1218`/`1219`/
`1306`/`1318`/`1216`/`1217` families) and their `#import`/`#include`/`#source` path refs
+ `expected/` files — needs a git-mv rename step; and **`issues/*.md`** (~20 writeups).
### Prior: Phase 9.1 + 9.2 — internal IDENTIFIER purge COMPLETE (commits 9.1a `b838f63`,
9.1b `b78e7dd`, 9.1c `cd14794`, 9.1d `7ffdc7d`, 9.2a `3354446`, 9.2b `5c8af6e`,
9.2b-fix `a15a868`, 9.2c `d27be42`, 9.2d `8cca3b9`). **Every `foreign` IDENTIFIER in
`src/` is renamed** — the only `foreign` left in `src/` is COMMENTS + the kept token
(`hash_foreign` + its `#foreignx` lexer-boundary test) + the rejection-message string.
Suite green (646/444) at every commit.
- **9.1d** eliminated the `foreign_expr` AST node: migrated `c_import.zig` auto-synth
to the extern shape, deleted the node + `ForeignExpr` + all readers.
- **9.2a/b/c/d** ran the runtime-class family rename (Decision 5 → `Runtime*`):
types `ForeignClassDecl`→`RuntimeClassDecl` etc.; fns `parseForeignClassDecl`→
`parseRuntimeClassDecl`, `lowerForeignMethodCall`→`lowerRuntimeMethodCall`, …; state
`foreign_class_map`→`runtime_class_map`, `foreign_class_decl` variant→
`runtime_class_decl`; the extern-ref validators → `Extern` (linkage, `checkExternRefs`);
the reference flag → **`is_extern`** (per user: reuse existing terminology, not a new
`is_reference`); and `foreign_path`→`runtime_path` COUPLED across the hook boundary
(build.sx accessor `jni_main_runtime_path_at` + the registered hook string +
bundle.sx + specs.md), with 37 `.ir` snapshots regenerated for the renamed
`@BuildOptions.jni_main_runtime_path_at` declare stub (symbol-name change only).
- **9.1a/b/c** (linkage): 5 collision-free renames (callExtern, …); "foreign symbol"
diagnostic + panic → "extern symbol" (1172 regen); deleted dead VarDecl legacy fields.
### Prior: Phase 9.1 (partial) — internal linkage-identifier purge (commits `b838f63` 9.1a,
`b78e7dd` 9.1b, `cd14794` 9.1c). **PHASE 9 STARTED.** Decision 6 = PURGE EVERYTHING,
scoped (user, 2026-06-15): purge `foreign` from **all `.sx` files + all documentation +
all our Zig (`src/`)**, analyzing each grep hit — **legitimate keeps stay**
(SQLITE_CONSTRAINT_FOREIGNKEY + other SQLite API constant names, vendored
`library/vendors/sqlite/c/*`, `1176-diagnostics-foreign-removed.sx` [the rejection test
MUST contain `#foreign`], the parser rejection-message string + `hash_foreign` token
[kept so `#foreign` keeps its friendly deprecation error]).
- **9.1a** (`b838f63`): 5 collision-free linkage renames — `callForeign`→`callExtern`,
`marshalForeignArg`→`marshalExternArg`, `dedupeForeignSymbol`→`dedupeExternSymbol`,
`foreign_name_map`→`extern_name_map`, `is_foreign_c_api`→`is_extern_c_api`.
- **9.1b** (`b78e7dd`): the "foreign symbol already bound" diagnostic (decl.zig) +
resolveFuncByName panic (call.zig) → "extern symbol". Intentional 1172 regen.
- **9.1c** (`cd14794`): **deleted** the dead `VarDecl.is_foreign`/`foreign_lib`/
`foreign_name` fields (the global `#foreign` path rejects → write-dead; 3 coalescing
readers in decl.zig simplified to `vd.extern_name`/`vd.is_extern`).
All snapshot-neutral except the intentional 1172 regen; suite green (646/444).
**COLLISION ANALYSIS (done — drives the rest of 9.1/9.2):**
- `is_foreign` lives on FnDecl?(no — flipped to `extern_export`), **VarDecl (deleted in
9.1c)**, and **ForeignClassDecl (ast.zig:903 — STILL LIVE**, distinguishes runtime-class
reference vs define; renamed in 9.2, not 9.1).
- `is_extern`/`extern_lib`/`extern_name` already exist (VarDecl + IR insts) — so the
old `foreign_*` linkage names could NOT be blind-renamed onto them; 9.1c deleted the
dead VarDecl trio instead of renaming.
- `foreign_expr` (25) is **still BUILT by `c_import.zig` auto-synthesis** (`#import c
{#include}` synthesizes fn bodies as `foreign_expr`). To eliminate it: migrate that
synth path to build the extern shape (empty-block body + `extern_export = .extern_`),
exactly the Phase 5.0 fn-body flip but for auto-synth — THEN delete the `foreign_expr`
node + all readers. This is the last 9.1 item.
### Prior: Phase 8 — CUTOVER: parser hard-rejects `#foreign` (`feat!` commit `3811311`,
preceded by the 8.0 xfail `8180faf` + 3 pre-cutover `refactor`s `2cce6a3`/`720556b`/
`d132aab`). **PHASE 8 COMPLETE.** The prefix `#foreign` linkage directive is removed:
all four parse sites (const-with-type 316, data global 425, fn body 2065, runtime-class
prefix via caller 260) reject it with the migration message *"`#foreign` has been
removed; use the postfix `extern` (import) / `export` (define) linkage keyword
instead"*; added a span-aware `failAt` for the runtime-class case (the lookahead
consumes the token before the reject decision). New example **1176**
(`diagnostics-foreign-removed`) pins it. **Pre-cutover migrations** (all green,
behavior-preserving): the 7 identity `ffi-foreign-*` test DECLS (`2cce6a3`), the two
keyword-neutral diagnostic tests 1172 + 1228 with intentional snapshot regens
(`720556b`), and the 4 multi-file example companions Phase 7 missed (0729/a+b, 1617/c,
1623/mod — `d132aab`). **Deleted** obsolete tests 1174 (`#foreign`+postfix conflict, now
unreachable) + 1620 (`#foreign nosuchunit`, superseded by extern twin 1231), the GATE
A→B unit test + `lowerSrcToIr` helper (nothing left to compare), and converted the
in-source `parse void function with foreign body` parser test to postfix `extern`.
specs.md + readme.md document `extern`/`export` as the sole C-linkage surface. Suite
green (646 corpus / 444 unit, 0 failed).
### Prior: Phase 7.4 — migrate straggler examples `#foreign`→`extern` (`refactor` commit
`1a8991a`). **PHASE 7 MIGRATABLE WORK COMPLETE (7.17.4 done).** Migrated 16 fn/global
examples across categories (0415/0602/0603/1024/1025/1605/1607-1609/1611/1616/1619/
1622/1628/1635/1636). Marker'd ones corpus-validated; the 3 unmarked uikit importers
(1607/1608/1616) verified byte-identical via `sx ir` probes. Empty snapshot diff; suite
green (647/444).
**Phase 7 net result:** every example that uses `#foreign` *incidentally* (FFI plumbing,
output-preserving) is now on `extern`/`export`. The **24 files still holding `#foreign`
are exactly the intended keep-list**, all deferred to the Phase 8 cutover:
- **`foreign`-asserting diagnostics** (migrating changes a snapshot): 1172, 1174, 1219
(stdout label), 1228 (equivalence test), 1620.
- **Identity `ffi-foreign-*` feature tests** (real decls; rename/dedup at cutover):
1205-global, 1205-global-helper, 1207, 1218, 1219, 1306, 1318.
- **Comment-only / provenance prose** (decls=0; `#foreign` only in comments): 0716, 0729,
1216, 1223, 1224, 1225, 1229, 1230, 1231, 1332, 1348, 1349, 1426, + issues/0030.
**Lesson (7.3):** the robust class-prefix transform is the GENERAL form
`s/#foreign\s+#(\w+)\((\"[^\"]*\")\)\s*\{/#$1($2) extern {/` — 1417 also used
`#jni_interface`/`#objc_protocol`/`#swift_class`/`#swift_struct`/`#swift_protocol`, and a
`#(objc|jni)_class`-only regex left `extern` in *prefix* position → parse error. All such
directives accept the postfix modifier (probed). Bare defined `#objc_class`/`#jni_class`
examples (no `#foreign`) were left untouched — not a purge target (define→export is an
optional consistency pass, deferrable).
### Prior: Phase 7.1 — migrate incidental 12xx ffi examples (`refactor` commit `731fb8d`).
Migrated 12 plain-C examples (1200/1206/1209-1215/1220/1221/1222); established the
keep-list policy above. Phase 7.2 (`a68f7c2`): 18 13xx obj-c examples (prefix→postfix
classes). Phase 7.3 (`2888f6f`): 13 14xx jni examples incl. 1417 multi-runtime.
### Prior: Phase 6.5 — migrate `gpu/` `#foreign`→`extern`; `library/` now `#foreign`-free
(`refactor` commit `32a7628`). **PHASE 6 COMPLETE.** Final batch: gpu/gles3.sx
(eglGetProcAddress + 1 comment) + gpu/metal.sx (MTLCreateSystemDefaultDevice), bare
fn markers → `extern`. Verified byte-identical `sx ir` on importers 1610 (gles3) +
1606 (metal). **Zero `#foreign` remains anywhere under `library/`** — verified by
`grep -rln '#foreign' library/` → no matches. Suite green (647 corpus / 444 unit, 0
failed).
### Prior: Phase 6.4 — migrate `ffi/` `#foreign`→`extern` (`refactor` commit `666a2e2`).
objc/objc_block/raylib/sdl3/wasm (~51 sites): fn markers + objc.sx's 2 import runtime
classes (prefix→postfix `extern`). objc + objc_block validated by the 50 marked 13xx
corpus examples (incl. import classes 1300/1301 + defined classes 1339/1349);
raylib/ffi-sdl3/wasm verified by byte-identical `sx ir` probes pre/post. Empty snapshot
diff; suite green.
### Prior: Phase 6.3 — migrate `std/` `#foreign`→`extern` (`refactor` commit `59f90d2`).
Pure source rename across 11 std modules (~60 sites):
cli/core/fmt/fs/log/net.kqueue/process/socket/thread/time/trace. All fn-decl markers
— bare `#foreign;`, `#foreign libc;`/`#foreign tlib;` (LIB ref), and
`#foreign libc "csym";` (LIB+rename) → the same `extern …` tail (`extern` carries the
identical `[LIB] ["csym"]` axis); plus 2 stale comment mentions (fmt/fs). No class
forms in std. These modules ARE host-corpus-exercised → empty snapshot diff is direct
validation. Suite green (647 corpus / 444 unit, 0 failed). Remaining Phase 6 batches:
6.4 ffi (~50, has runtime classes), 6.5 remainder.
### Prior: Phase 6.2 — migrate `platform/` `#foreign`→`extern`/`export` (`refactor` commit
`2cd5d7b`). Pure source rename across uikit/android/android_jni/sdl3 (~64 sites):
30 fn `#foreign;`→`… extern;`; 34 import runtime classes
`#foreign #objc_class/#jni_class("X") {`→`#…_class("X") extern {` (prefix→postfix);
4 defined `Sx*` obj-c classes `#objc_class("X") {`→`… export {`. Behavior-preserving;
empty snapshot diff. **Verification (these modules are largely uncompiled by the
host corpus** — bundle examples import `bundle.sx`, not the runtime modules; android.sx
only compiles under `OS==.android`): byte-identical `sx ir` on uikit importers 1610 +
1606 (which DO compile uikit incl. the 4 defined `Sx*` classes on host) and an sdl3
direct-import probe; android.sx verified by an identical 4-error dedup set (host
pthread clashes — the keyword-neutral "foreign symbol already bound" dedup message is
unchanged, and the probe parsed all migrated `extern` jni classes + EGL fns cleanly
before hitting them). Suite green (647 corpus / 444 unit, 0 failed). Remaining Phase 6
batches: 6.3 std (~60), 6.4 ffi (~50), 6.5 remainder.
### Prior: Phase 6.1 — migrate `vendors/sqlite` `#foreign`→`extern` (`refactor` commit
`410a52e`). **PART B PHASE 6 STARTED.** Pure source rename: all 97
`sqlite3_* … #foreign sqlib "csym";` fn decls → `extern sqlib "csym";` (+ the one
stale header-comment reference, line 9). The `extern_lib` axis references the `sqlib`
`#import c` unit identically to `#foreign sqlib`, so IR/output is byte-identical —
empty snapshot diff (only `sqlite.sx` changed), and example 1624
(`vendor-sqlite-module`) stdout byte-unchanged. Suite green (647 corpus / 444 unit,
0 failed).
### Prior: Phase 5.1 — annotate A→B gate post-flip + add fn-rename case (`test` commit
`93e7b6f`). **PHASE 5 COMPLETE → PART B Phase 5 done.** The A→B gate
(`lower.test.zig`) already asserted `#foreign` ≡ `extern`/`export` byte-identical IR
for fn / global / Obj-C class; post-Phase-5.0 the fn-decl + data-global paths build
the SAME extern-named AST, so cases 1/2 are now STRUCTURALLY identical (guaranteed by
construction, not coincidence). Annotated the gate header to record this and keep it
as a regression tripwire (catches a future reader re-diverging the spellings, or a
revert of the flip); case 3 (runtime class) stays behaviorally — not structurally —
equal via the single `is_foreign_eff` field. Added a fn-rename case (case 2b,
`extern_name` axis: `c_abs` → `"abs"`) to broaden coverage beyond bare import
(verified IR-identical via `sx ir` probe before adding). Test-only, no snapshot churn.
Suite green (647 corpus / 444 unit, 0 failed).
### Prior: Phase 5.0 — fn-decl `#foreign` body-marker FLIP (`refactor` commit `6b94bb6`).
**PHASE 5.0 PARSER ROUTING COMPLETE.** The fn-body `#foreign [LIB] ["csym"]` marker
now builds the SAME extern AST postfix `extern` produces (`extern_export = .extern_`
+ `extern_lib`/`extern_name` + empty-block body) instead of a `foreign_expr` body.
Behavior-preserving — all four prereqs (visibility, variadic, plain-free, lib-ref)
ensure every downstream reader coalesces `is_foreign` with `extern_export`, so IR +
runtime are byte-identical (full corpus + A→B gate green). Decision 7 churn realised:
example 1620's lib-ref error flips "#foreign library" → "extern library" (the only
snapshot moved; hand-edited, not regen). Parser unit test updated to assert the extern
shape. Spot-checked 1219/1218/0729 (foreign rename / cvariadic / same-name) end-to-end.
**All four `#foreign` parser paths now resolved:** global (`e5ddfbe`) + fn-body
(`6b94bb6`) flipped onto extern; const-with-type is dead (deferred); runtime-class is
already coalesced (`is_foreign_eff`). `c_import.zig` auto-synthesis STILL emits
`foreign_expr` bodies (Phase 6+), so both shapes coexist — every reader stays dual.
Suite green (647 corpus / 444 unit, 0 failed).
### Prior: Phase 5.0 prereqs 3 & 4 — plain-free classification + extern lib-ref validation
(plain-free: xfail `2706521` → fix `3c94c14`; lib-ref: xfail `38c3240` → fix
`ad6aed3`). Two MORE extern/#foreign divergences found while de-risking the fn-path
flip, both now closed. **FOUR prereqs total done — the fn-decl flip fully de-risked.**
- **Prereq 3 (plain-free):** `isPlainFreeFn`/`isPlainFreeFnDecl` (resolver.zig:178,
generic.zig:815) excluded a `#foreign` body but classified an empty-block `extern`
fn as a plain free fn — so existing extern fns were wrongly counted in the bare-call
ambiguity verdict (example: two same-name `extern libc "abs"` authors errored
ambiguous, while the `#foreign` twin 0729 compiles). Both predicates now also
exclude `extern_export == .extern_`; `export` (real body) stays plain-free. Example
**1230**.
- **Prereq 4 (lib-ref validation):** `checkForeignRefs` (c_import.zig) validated only
`foreign_expr.library_ref`, so a bogus `extern nosuchunit "abs"` compiled silently
while `#foreign nosuchunit` errors (1620). Now reads the lib ref from EITHER spelling
and names the surface keyword in the diagnostic (so 1620 stays byte-unchanged).
Example **1231**.
- Two OTHER classifying sites probed and found BENIGN for extern (no flip prereq):
namespace/qualified dispatch (`registerQualifiedFn` decl.zig:2208, namespace gate
call.zig:729) — a namespaced `extern` fn resolves identically to its `#foreign` twin
(probe: `cm.c_abs(-9)` → 9 both ways; the registered qualified alias resolves to the
same extern symbol).
### Prior: Phase 5.0 prereq — extern C-variadic tail (xfail `9a2c78d` → fix `0fdc821`) — the SECOND deferred fn-path prerequisite. **BOTH original fn-path prereqs done.** The C-variadic `...` handling was keyed on the `#foreign` (`foreign_expr`)
body shape at two sites — the `is_variadic` drop in `declareFunction`
(`decl.zig:2097`) and the call-site early-out in `packVariadicCallArgs`
(`pack.zig:302`). A variadic `extern` therefore kept its trailing slice param and
slice-packed the extras → garbage at the C ABI (probe: `sum_ints(3,10,20,30)` →
53316585, not 60). Both gates now also fire for `extern_export == .extern_`, so a
variadic `extern` drops the `..args: []T`, sets `is_variadic`, and passes extras
through the C `...` slot with default argument promotion — byte-identical to its
`#foreign` twin. New example **1229** (`1229-ffi-extern-cvariadic`, JIT `#source`,
int-sum + double-avg). Suite green (645 corpus / 444 unit, 0 failed).
### Prior: Phase 5.0 prereq — visibility-gate equivalence (xfail `717c35d` → fix `7d8ba1a`) — the first of the two deferred fn-path prerequisites.
The non-transitive C-import visibility gate (`isVisible(.c_import_bare)`,
`decl.zig:2249`) used to recognise only the legacy `#foreign` body shape; a bare
`extern` fn (empty-block body + `extern_export == .extern_`) escaped the gate 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 `#foreign LIB`)
stays unconditionally visible — so a future fn-decl `#foreign`→`extern` migration
is byte-identical at this gate. New cross-module example **1228**
(`examples/1228-ffi-extern-c-non-transitive`, main → b → c) pins the equivalence:
referencing c's lib-less `#foreign` AND `extern` twins transitively both produce
the identical C-specific diagnostic. Suite green (644 corpus / 444 unit, 0 failed).
**Empirical finding** (probe, not yet acted on): the bare-extern twin was NEVER a
silent visibility hole — the general `isNameVisible` gate already denied it; only
the *diagnostic wording* diverged. The fix aligns the wording + gate ownership.
### Prior: Phase 5.0 (global path) (`refactor` lock, commit `e5ddfbe`) — **PART B STARTED.**
First of the four `#foreign` parser paths migrated onto the extern AST: the
data-global form `name : T #foreign [lib] ["csym"];` now builds the same
extern-named `VarDecl` (`is_extern`/`extern_lib`/`extern_name`) that postfix
`extern` already produces, instead of `is_foreign`/`foreign_lib`/`foreign_name`.
Behavior-preserving — lowering coalesces both forms identically
(`decl.zig:1119,1127,1141`), so zero snapshot churn. The fn-decl, const-with-type,
and runtime-class `#foreign` paths still build the legacy AST.
### Prior: Phase 4 (green) — **PHASE 4 COMPLETE → PART A DONE; GATE A→B LOCKED.** Four pieces:
(1) **GATE A→B unit test** (`lower.test.zig`, `lowerSrcToIr` helper + "GATE A→B" test) —
asserts `#foreign` and `extern`/`export` lower to byte-identical printed IR for a sample
fn, data global, and Obj-C runtime class. This is the hard gate: Part B may not start
migrating `#foreign` until it's green. Verified live (negative-probe: mutating one side
fails the assertion). (2) **Diagnostic — `#foreign` + postfix conflict** (1174): prefix
`#foreign` combined with postfix `extern`/`export` on an aggregate is now a clean parse
error (was a confusing internal "compiler bug" during class synthesis). (3) **Diagnostic
— `extern`+`export` mutual exclusion** (1175): both keywords on one fn decl is a clean
error (was bare "expected ';'"). (4) **Docs**: `specs.md` + `readme.md` document the three
`extern`/`export` axes (fns, globals, aggregates) alongside `#foreign` (which stays
documented until the Part B cutover). Suite green (643 corpus / 444 unit, 0 fail).
NOTE: `extern`+`callconv` redundancy needs no diagnostic — `callconv(.c) extern` is a
harmless dup (both `.c`), and any non-`.c` callconv already errors on its own.
### Prior: Phase 3.1 (green) — **PHASE 3 COMPLETE.** Postfix `extern`/`export` on `#objc_class`/
`#jni_class` aggregates fully works. `parseForeignClassDecl` now parses an optional
`extern`/`export` modifier in the slot **between** the `("X")` directive args and the `{`
body (`parser.zig:~1409`): `extern`→`is_foreign_eff = true` (reference an existing runtime
class, == legacy `#foreign`); `export`→`is_foreign_eff = false` (define + register a new sx
class, == bare `#objc_class` with no `#foreign`). The modifier maps straight onto the same
`is_foreign` decision the prefix `#foreign` already fed the node, so **no `objc_class.zig`/
lowering change was needed** — the new surface reuses the existing reference-vs-define path.
Examples: **1348** (objc `extern` import, dispatches `NSObject.alloc().init()` → green via
JIT), **1349** (objc `export` defined class, `SxBar.alloc()`/`bump`/`get` → `counter: 2`),
**1426** (jni `extern` import, parse-only `parse-only ok`). Suite green (641 corpus / 443
unit, 0 fail).
### Prior: Phase 2.2 (green) — **PHASE 2 COMPLETE.** `export` (define + expose) fully works:
external linkage + C ABI + no sx ctx + force-lowered root + optional `"csym"` rename.
All four export-gap conditions filled in `decl.zig`: (i) `.external` linkage for
`extern_export == .export_` on both define paths (`lowerFunctionBodyInto`,
`lowerFunction`); (ii) C-ABI promotion on the define paths + `declareFunction` stub cc;
(iv) `funcWantsImplicitCtx` returns false for any non-`.none` modifier; **force-lower**:
`export` fns are lowering roots in `lowerMainAndComptime` (else an uncalled export fn
stays a bodiless `declare`); (iii) `export … "csym"` declares the stub under the C name
+ `lazyLowerFunction` promotes the body into it via `foreign_name_map`. Examples **1226**
(bare export, C calls `sx_square` → 37/82) + **1227** (`export "triple_c"`, C calls
`triple_c` → 22) green via the new **AOT corpus mode**. Suite green (638 corpus / 443
unit, 0 fail).
**AOT corpus mode + run_examples.sh retired.** C→sx-by-name can't link 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 an `expected/<name>.aot` marker switches an example to a
`sx build` + execute flow. The standalone `tests/run_examples.sh` was deleted —
`zig build test` is now the sole corpus runner (verify-step.sh + CLAUDE.md updated).
## Current state
Syntax: bare `extern`/`export`, postfix after `callconv(.c)`, `extern ⇒ callconv(.c)`.
**Decision 4 revised** (user 2026-06-14): `extern` carries an optional `LIB`+`"csym"`
axis (`extern_lib`/`extern_name`) like `#foreign`; the `#library` decl + build-flag
linking stays separate. **`extern` (PHASE 1) + `export` (PHASE 2) FULLY WORKING.**
extern: functions — bare (`f :: (…) -> R extern;`) AND renamed (`extern [LIB] "csym"`);
data globals — bare + renamed. export: functions — bare (`f :: (…) -> R export {…}`)
AND renamed (`export "csym"`); external linkage, C ABI, no ctx, force-lowered as a root.
All behavior-equivalent to the matching `#foreign` form. `extern_lib` is parsed + stored
but is a *reference* only — actual linking stays the `#library`/build-flag axis.
**Aggregates DONE (Phase 3)**: postfix `extern`/`export` on `#objc_class`/`#jni_class`
(reference vs define+register). **Interplay/diagnostics/docs DONE (Phase 4)** + the
**A→B GATE IS LOCKED** (`#foreign` ≡ `extern`/`export` IR for fn/global/class). **PART A
COMPLETE.** Part B `foreign` footprint to purge: 643 lines / ~57 identifiers in `src/` +
~28 doc lines. End-state invariant: **zero `foreign`** (Phase 9.4 gate). Examples: 1223
(extern bare fn), 1224 (extern fn rename), 1225 (extern bare global), 1226 (export bare fn,
AOT), 1227 (export fn rename, AOT), 1348 (objc extern class), 1349 (objc export class), 1426
(jni extern class), 1174/1175 (interplay diagnostics).
## Next step
**NONE — the FFI-linkage stream is COMPLETE.** `extern`/`export` fully replace
`#foreign`; the keyword is rejected; zero `foreign` remains in the gated tree (Parts
A + B, Phases 09 all done; the 9.4 gate passes). This stream can be archived.
Follow-ups (both DONE 2026-06-15, post-stream polish):
- ✅ Added `extern`/`export` to the editors/vscode tmLanguage keyword list as a
`storage.modifier.sx` pattern (`editors/vscode/syntaxes/sx.tmLanguage.json`).
- ✅ Dropped the vestigial `RuntimeClassPrefix.is_extern` field +
`parseRuntimeClassDecl`'s `is_extern` param (always-false dead path; the postfix
`extern`/`export` keyword is the sole reference-vs-define decider). Suite green
(644 corpus / 442 unit, 0 failed).
--- (historical: the finish-Phase-9 plan, now done) ---
**PART B — finish Phase 9: example FILENAME renames + `issues/*.md` + 9.0/9.4.**
(All `src/` identifiers + AST node + all comments/docs/example-comments are DONE.)
0. **FIRST: commit the staged `examples/` comment purge** (a classifier outage blocked
the commit; changes are `git add`ed). Message: "refactor(ffi-linkage): Phase
9.3-examples — purge 'foreign' from example .sx comments".
1. **Example filename rename** (git-mv step, snapshot-careful): rename the `*-foreign*`
example files to extern/runtime names and update every `#import`/`#include`/`#source`
ref + the `expected/<name>.*` companions. Families: `0729-modules-flat-same-name-foreign`
(+ `/a.sx`,`/b.sx` dir), `1205-ffi-foreign-global`(+`-helper`), `1207-ffi-foreign-global-from-helper`,
`1216-ffi-08-foreign-in-method`(+`.h`/`.c`), `1217-ffi-09-foreign-result-chain`(+`.h`/`.c`),
`1218-ffi-foreign-cvariadic`(+`.c`), `1219-ffi-foreign`, `1306-ffi-objc-foreign-class-chained-dispatch`,
`1318-ffi-objc-property-foreign`. ⚠ A renamed file with an `.ir`/`.stderr` snapshot that
echoes its own path will need that snapshot regenerated (intentional). Pick new names
that drop "foreign" (e.g. `…-extern-global`, `…-extern-in-method`, `…-runtime-class-chained-dispatch`).
NOTE: keep `1176-diagnostics-foreign-removed.sx` name (it's the rejection test — fine to keep "foreign").
2. **issues/*.md** (~20) — rewrite writeup prose `#foreign`/`foreign`→`extern`/`runtime-class`.
2b. **`docs/*.md`** — ALSO in the gate scope (was missed; the gate areas are now
`src/ library/ examples/ issues/ docs/ specs.md readme.md CLAUDE.md`). `docs/debugger.md`
referenced the renamed `callForeign` (fixed → `callExtern`, UNCOMMITTED with the staged
batch); sweep all of `docs/` for stale renamed-identifier refs + `foreign` prose.
3. **9.0 surface decision — RATIFIED (user, 2026-06-15): DELETE the `hash_foreign` token.**
The user explicitly flagged token.zig:121 + lsp/server.zig:1693 "this also needs to
go" — total purge, accept `#foreign`→generic error (no friendly migration hint). This
is the LAST src change; it is load-bearing → needs a build + test + 1176 regen (do it
when mutating Bash is back). Steps:
- token.zig: remove `hash_foreign` enum (121).
- lexer.zig: remove the `.{ "#foreign", Tag.hash_foreign }` map entry (91), drop
`#foreign` from the directive-list comment (72), DELETE the `lex hash_foreign` test
(626-631, incl. `#foreignx`).
- parser.zig: remove the 4 `self.current.tag == .hash_foreign` rejection sites (268
caller / 327 / 419 / 2024) + their messages, AND the 2 lookahead refs (`hasFnBody…`
~3658 + ~3676). ⚠ Decide what `#foreign` lexes to with no keyword entry (likely an
error/unknown-directive token) and confirm the parser surfaces a sane error.
- lsp/server.zig: remove the `.hash_foreign,` arm (1693).
- **1176-diagnostics-foreign-removed**: its expected stderr is the now-deleted
"`#foreign` has been removed…" message → it WILL change. Regen 1176's snapshot to
whatever the generic post-deletion error is (intentional), OR delete 1176 entirely
(its purpose — a friendly rejection — no longer exists). Recommend: keep 1176 as a
"`#foreign` is no longer a directive" regression, regen its snapshot. NOTE: after
this, 1176 may still contain `#foreign` in its SOURCE (the rejected token) — that's
the only legitimately-remaining `foreign` in `.sx`, OR rename/rework it to avoid even
that if the gate must be absolute.
4. **9.4 gate** — `grep -rniIE 'foreign'` over `.sx` + docs + `src/` → 0 (no keep-list
left except possibly 1176's source token + `SQLITE_CONSTRAINT_FOREIGNKEY` + vendored C).
--- (historical: the prose-purge plan, now mostly done) ---
**PART B — finish Phase 9: the COMMENT / DOC / issues text purge** (all `src/`
identifiers + the AST node are already done; remaining is prose). Lower-risk than the
renames (text only, mostly snapshot-neutral) but needs per-instance reading — NOT a
blind sed. Footprint: `src/` ~205 (all comments now), `examples/*.sx` ~100 comments,
`issues/*.md` ~20 files, docs (specs/readme/CLAUDE).
Order:
1. **src/ comments** (~200) — reword `foreign`→`extern`/`runtime-class` to match the
renamed identifiers. KEEP: the rejection-message string, the `hash_foreign` token +
its `#foreignx`/`lex hash_foreign` test, and any comment that legitimately explains
the cutover (it must name `#foreign` to say it's removed). The ast.zig FnDecl comment
still says "mirroring `#foreign LIB "csym"` (foreign_lib/foreign_name)" — reword.
2. **examples/*.sx comments** — the deferred provenance comments (full list in the prior
Next-step revision / git). ⚠ Many CONTRAST `#foreign` vs `extern` — reword to stay
coherent. ⚠ `1219-ffi-foreign.sx` prints `"foreign-rename: {}"`/`"=== 15. Foreign ==="`
to STDOUT — changing those regens its snapshot (intentional). `1176`/`1216` legitimately
discuss `#foreign` removal — keep minimal `#foreign` mentions where the test IS about it.
3. **issues/*.md** (~20) — rewrite writeup prose to `extern`/`export`/`runtime-class`.
4. **docs** — specs.md (rename "Foreign Function Interface" heading → "C Interop"; the
`#import c` "foreign declarations" prose; the comptime "foreign function calls" line;
§3344 "foreign code can't observe the error channel"), readme.md (211-212 the `#import
c` exemption prose), CLAUDE.md (host_ffi `#foreign("c")` ref → `extern`; "foreign calls").
5. **9.0 surface decision** (recommend KEEP `hash_foreign` token + rejection for a good
deprecation — then it + the message + 1176 + `#foreignx` are permanent gate-exempt keeps).
6. **9.4 gate** — `grep -rniIE 'foreign'` over `.sx` + docs + `src/` minus the keep-list → 0.
KEEP-LIST (gate-exempt): `SQLITE_CONSTRAINT_FOREIGNKEY` + SQLite API names, vendored
`library/vendors/sqlite/c/*`, the `hash_foreign` token + `#foreignx` test + rejection
message string, `1176-diagnostics-foreign-removed.sx` (rejection test must contain it).
**Gate (scoped per user 2026-06-15):** `grep -rniIE 'foreign'` → 0 across `.sx` files,
all docs, and our `src/` Zig — EXCLUDING the legitimate keeps listed in Last completed
step (SQLite API names, vendored C, the rejection test/message + `hash_foreign` token).
Remaining, in suggested (dependency-safe) order:
1. **9.1d — eliminate `foreign_expr`** (last linkage item): migrate `c_import.zig`
auto-synthesis to build the extern shape instead of a `foreign_expr` body (the Phase
5.0 fn-body flip applied to auto-synth), then delete the `foreign_expr` AST node +
`ForeignExpr` + all readers (25). Snapshot-neutral; verify full corpus (the `#import c`
examples 1215/1216/1217 + sqlite 1624 exercise it).
2. **9.2 — runtime-class family rename → `Runtime*` (Decision 5).** The BIG one, do as
small per-identifier commits with `zig build` after each (snapshot-neutral). Targets
(counts): `ForeignClassDecl`(65)→`RuntimeClassDecl` · `foreign_path`(62)→`runtime_path`
· `foreign_class_map`(44) · `current_foreign_class`(34)/`_method` · `ForeignMethodDecl`(31)
· `foreign_class_decl`(30) · `foreign_expr`-gone-by-now · `ForeignClassMember`(20) ·
`ForeignFieldDecl`(15) · `ForeignClassDecl.is_foreign`(the live one)→e.g. `is_reference`
· `parse/tryParseForeignClass*` · `lowerForeign{Method,Static}Call` ·
`findForeign*InChain` · `resolveForeign*` · `register*ForeignClass*` · `*ForeignRefs` ·
`ForeignRuntime` · `current_foreign_class`/`_method`. ⚠ COUPLED .sx↔.zig hook names:
`jni_main_foreign_path_at`/`jni_main_foreign_paths`/`hookJniMainForeignPathAt`/
`foreignPathToJavaName`/`splitForeignPath` span build.sx + bundle.sx + compiler_hooks.zig
+ specs.md (2975/3049) — rename all four sites together.
3. **9.x-src-comments** — the ~200 bare-`foreign` comments in `src/` (rename last, since
many reference identifiers that 9.1d/9.2 rename; do AFTER those so the comment text
matches the new names).
4. **9.3-examples comments** — the deferred `.sx` provenance comments (0716, 0729, 1205/
1207/1216/1218/1219/1220, 1223-1231, 1306/1308/1315/1318/1320/1321/1331/1332/1348/1349,
1412/1414/1417/1418/1419/1426, 0117/0415, 1140/1141/1125, issues/0030.sx). ⚠ Many
CONTRAST `#foreign` vs `extern` ("no `#foreign`, no `#library`") or reference renamed
internals — rewrite each to stay coherent (NOT blind sed). ALSO: `1219-ffi-foreign.sx`
prints `"foreign-rename: {}"` to STDOUT — changing it regens the snapshot (intentional).
5. **9.3-issues** — `issues/*.md` writeups (~20 files) → rewrite `#foreign`/`foreign` to
`extern`/`export`/`runtime-class` per the renames.
6. **9.3-docs** — specs.md (12: rename "Foreign Function Interface" heading → "C Interop";
the `#import c` "foreign declarations" prose; the jni_main_foreign_path_at refs with #2),
readme.md (2), CLAUDE.md (2: host_ffi `#foreign("c")` ref + "foreign calls").
7. **9.0 surface decision** — keep `hash_foreign` token + rejection (recommended: good
deprecation) vs delete it. If kept, the token + the rejection-message string + 1176 are
permanent legitimate keeps; the gate excludes them.
8. **9.4 gate** — `grep -rniIE 'foreign'` over the gated set minus the keep-list → 0.
- **6.2 verification note (carry forward):** the `platform/` runtime modules
(uikit/android/android_jni) are NOT compiled by any marker'd host corpus test — verify
future platform-adjacent migrations via direct `sx ir` on importers (1610/1606 compile
uikit on host) or import probes, not the corpus alone.
- **Phases 67** (`refactor` batches, empty snapshot diff per batch): migrate the
stdlib + examples from `#foreign` spelling to `extern`. Because the AST is already
unified, this is a pure SOURCE rename (`#foreign LIB "sym";` → `… extern LIB "sym";`
for fns; the global/const forms similarly), and IR/output must be byte-identical per
batch. NOTE: `c_import.zig` auto-synthesis (`#import c {#include}`) still BUILDS
`foreign_expr` bodies internally — that's a compiler-internal path, migrated separately
(likely Phase 8/9 area), not a source-spelling change.
- **Then Phase 8** (cutover: hard-reject the `#foreign` keyword) and **Phase 9** (purge
all `foreign` identifiers — needs Decision 5 [done, `Runtime*Class*`] + Decision 6
[open, historical carve-out]).
**Watch items carried forward:**
- `c_import.zig:262` auto-synthesis still emits `foreign_expr` — both shapes coexist
until that path is migrated; keep every `body.data == .foreign_expr` reader dual
(checked exhaustively this stream).
- const-with-type `#foreign` parser path (`parser.zig:316`) is still on `foreign_expr`
but DEAD (registers no const); migrate or delete it at the Phase 8 cutover.
- The `decl.zig:2055` "foreign symbol … already bound" dedupe message is keyword-neutral
and fires for both forms — no churn, but reword to "extern" at cutover for consistency. Route the fn-decl `#foreign` path so a
`#foreign` fn builds the SAME extern AST that postfix `extern` already produces,
instead of a `foreign_expr` body. This is the highest-value path (the bulk of
`#foreign` usage). Key sub-questions to resolve before/while routing:
- The `foreign_expr` node carries `library_ref` + `c_name`; the `extern` fn carries
`extern_export = .extern_` + `extern_lib` + `extern_name` on the FnDecl with an
empty-block body. Migration = the parser's fn-body `#foreign` arm
(`parser.zig:~2062`) builds the extern shape (set `extern_export`, map
`library_ref→extern_lib`, `c_name→extern_name`) rather than a `foreign_expr`.
- Lowering ALREADY coalesces the two at every fn site checked this stream
(`decl.zig` 2088/2124/2132/2156/2324/2531 read `is_foreign OR extern_export`),
and the two prereq gates (visibility `decl.zig:2249`, variadic `decl.zig:2097` +
`pack.zig:302`) now do too — so the migration should be behavior-preserving with
ZERO snapshot churn. VERIFY with the A→B gate test (`lower.test.zig`) + a full
`zig build test` after routing; any churn means a site still reads `foreign_expr`
structurally and must be coalesced first.
- ⚠ This ALSO migrates the **const-with-type** path implicitly IF it shares the same
`foreign_expr`→extern reshape (it builds `const_decl{value=foreign_expr}`). Decide:
reshape the const path's value node alongside, or leave the dead const path on
`foreign_expr` until Phase 8 cutover. The const path is dead (see findings below),
so leaving it is acceptable; but the parser arm is shared-ish — check whether the
fn-body arm change touches it.
- Cadence: because the migration is behavior-preserving (no churn), it's a single
`refactor`/lock commit (like the 5.0 global-path commit `e5ddfbe`), NOT an
xfail→fix pair.
**Investigation findings (this session — reorder the remaining paths):**
- **const-with-type** (`parser.zig:316`, `name :: type_expr #foreign`) is a
**DEAD path**: it builds `const_decl{value = foreign_expr}`, but
`registerTypedModuleConst` (`decl.zig:848-851`) bails on a `foreign_expr` value
(`else => return`), so it registers no const and emits no symbol — a probe
(`g_abs :: FP #foreign "abs";`) returns `unresolved 'g_abs'` at the use site, and
the form is used NOWHERE in `library`/`examples`/`issues`. Its migration target is
ambiguous because the `foreign_expr` value node is SHARED with the fn-decl path,
which isn't migrated yet. **Decision (user, 2026-06-14): defer it — migrate it
alongside the fn-decl path once `foreign_expr`'s extern shape is decided.** The
checkpoint's old "lowest-risk, route to the extern-named shape" note is wrong: the
"confirm the value-node lowering path coalesces" gate can't be met (nothing lowers it).
- **runtime-class prefix** (`parser.zig:~1351`, `#foreign #objc_class/#jni_class`) is
**ALREADY coalesced**: both prefix `#foreign` and postfix `extern` feed the single
`is_foreign_eff`→`is_foreign` field on `foreign_class_decl` (`parser.zig:1421-1432`),
so there is NO Phase 5.0 AST change for it — only the Phase 9.2 `Runtime*Class*`
rename remains. Drop it from the Phase 5.0 path list.
So Phase 5.0's real remaining work collapses to: the fn-path variadic prereq, then
the fn-decl `#foreign` body-marker migration. const-with-type + runtime-class need
no standalone Phase 5.0 commit.
Then Phase 5.1 (`lock`): unit test that `#foreign` and `extern` produce identical IR (the
A→B gate already covers fn/global/class — extend or reuse `lowerSrcToIr`). Then Phases 67
migrate stdlib + examples (empty snapshot diff per batch), Phase 8 cutover (hard-reject
`#foreign`), Phase 9 total `foreign` purge.
**⚠ CONFIRM BEFORE PART B (Open decisions 5 & 6):** runtime-class rename target
(`Runtime*Class*` recommended vs `Extern*Class*`) and the historical carve-out (keep
`issues/*.md` provenance, gate live tree only — recommended). These decide Phase 9 renames;
the plan says confirm before Phase 9, but worth raising with the user before sinking Part B
effort. **Also pick up the two Deferred items below at the start of Part B** (the
visibility-gate equivalence in particular needs a cross-module example).
**FUTURE MILESTONE — C→sx-by-name in JIT (`sx run`).** Investigated this session
(user-requested spike, RESOLVED feasible-but-blocked). Adding the C `#source` objects
directly into the ORC JITDylib (`LLVMOrcLLJITAddObjectFile`) instead of dlopen'ing a
dylib makes C↔sx cross-references resolve both ways in one link domain — proven: a
~20-line spike ran 1226 via `sx run` (37/82) and all 13 existing `#source` FFI examples
still passed. BLOCKER: C objects using `_Thread_local` (the return-trace runtime
`sx_trace.c`) SIGABRT under JITLink — MachO thread-local-variable handling needs the ORC
`MachOPlatform` set up (the bare `LLVMOrcCreateLLJIT` default doesn't), and C
constructors/`__mod_init_func` won't run without ORC initializer support. 42 `errors-*`
examples crashed in the spike. A real impl needs a C++ shim in `llvm_shim.c`
(`LLJITBuilder().setObjectLinkingLayerCreator(...)` + `MachOPlatform::Create`) — its own
milestone, NOT Phase 2/3 scope. The AOT `.aot`-marker corpus mode is the pragmatic test
path and works today. Spike fully reverted (target.zig/main.zig at HEAD).
**Deferred (carry into Part B):** (a) ~~docs~~ — DONE in Phase 4 (`specs.md`/`readme.md`
document `extern`/`export`; `#foreign` stays until the Part B cutover); (b) ~~visibility-gate
equivalence~~ — **DONE** (`717c35d`/`7d8ba1a`): the `c_import_bare` gate now polices a
lib-less `extern` fn identically to its lib-less `#foreign` twin (same C-specific
diagnostic); a library-bound `extern LIB` stays unconditionally visible. Locked by the
cross-module example 1228. (Empirical: the bare-extern twin was never a silent hole — the
general `isNameVisible` gate already denied it; only the diagnostic wording diverged.)
## Open decisions
Part A ratified (bare / postfix / `⇒ callconv(.c)` / lib-separate). Part B:
- **Decision 5 RATIFIED** (user, 2026-06-14): runtime-class rename target = `Runtime*Class*`
(object-model axis, not linkage). Drives the Phase 9.2 identifier renames.
- **Decision 6 RATIFIED** (user, 2026-06-15): **PURGE EVERYTHING** — the Phase 9.4 gate is
absolute, including `issues/*.md` writeups (NOT the recommended keep-provenance default).
Every `#foreign`/`foreign` reference in the gated tree (`src/ library/ examples/ issues/
specs.md readme.md CLAUDE.md`) is rewritten to `extern`/`export`; provenance lives in git
history + `(Regression issue NNNN)` notes, not the keyword spelling.
- **Decision 7 RATIFIED** (user, 2026-06-15): **accept the churn** — `#foreign`-spelled
decls produce `extern`-worded diagnostics; example 1620 regenerated (only snapshot moved).
Aligns with Part B's extern-only end state; the interim oddity is cosmetic and removed at
the Phase 8 cutover. Landed in the fn-body flip `6b94bb6`. (Original framing below.)
— interim diagnostic wording for `#foreign`-spelled decls (gated the fn-body flip). Once the flip lands, a `#foreign`-spelled fn builds the extern AST, so any
diagnostic that reads the unified AST can no longer tell the user wrote `#foreign` vs
`extern`. Concretely, example 1620's lib-ref error flips "#foreign library…" →
"extern library…". Options: **(A, recommended)** accept the narrow churn — regen 1620 as
intentional; it aligns with Part B's `extern`-only end state and the interim oddity
(`#foreign` source → "extern" message) is cosmetic and short-lived (Phase 8 cutover
removes `#foreign`). **(B)** retain a one-bit surface marker on `FnDecl` (`wrote_foreign`)
so interim diagnostics stay keyword-accurate (zero churn, small extra plumbing, marker
deleted at cutover). Affects only diagnostic wording — IR/behavior identical either way.
## Log
- (9.0 + 9.3 + 9.4) **PHASE 9 COMPLETE — STREAM DONE; 9.4 GATE PASSES.** Deleted the
hash_foreign token (9.0, `dfae690`); purged all `foreign` comments incl. capital-F
(src/examples/docs/editors); renamed 10 `*-foreign*` example files + dedup'd 1218
(`b52d424`); rewrote 20 issues/*.md + renamed 0043 (`b9cfe25`). Gate: zero `foreign`
in the gated tree except `SQLITE_CONSTRAINT_FOREIGNKEY` + vendored sqlite c/. Suite
green (644/443). User flagged several leftover areas mid-purge (docs/, editors/,
capital-Foreign comments, the token) — all addressed.
- (9.3 src capital-Foreign) Fixed the case-sensitivity gap — my earlier src verify grep
was case-sensitive, missing ~21 capital `Foreign`/`FOREIGN` comments (Foreign-class→
Runtime-class, Foreign path→Runtime path, Foreign decls→Extern decls, FOREIGN function→
extern function, etc.) across calls/inst/ffi_objc/jni_descriptor/emit_llvm/c_import/
lower.* /ops.zig. All reworded via Edit (comments only — no build impact). UNCOMMITTED
(mutating Bash blocked by a classifier outage). After this, src `foreign` = ONLY the
`hash_foreign` token machinery + 4 rejection messages (the 9.0-delete targets).
- (9.0 RATIFIED) User: DELETE the hash_foreign token (total purge). Pending build+regen.
- (9.3 text purge) Purged `foreign` from all `src/` comments (`dc51c4b`), specs/readme/
CLAUDE (`e99383f`), and all example .sx comments (STAGED, commit pending a classifier
outage). Fixed 2 user-facing diagnostics (type-annotation error, Android jni_main help).
1219 stdout labels Foreign→Extern (regen). Suite green (646/444). Remaining: example
FILENAMES + issues/*.md + the 9.0 token decision + 9.4 gate.
- (9.2a-d) **RUNTIME-CLASS IDENTIFIER PURGE COMPLETE** (Decision 5 → `Runtime*`).
9.2a types (`3354446`), 9.2b fns+state+`is_extern` flag (`5c8af6e`, fixed `a15a868`
per user: reuse `is_extern` not new `is_reference`), 9.2c extern-ref validators →
`Extern` (`d27be42`), 9.2d `foreign_path`→`runtime_path` coupled across the build-hook
boundary + 37 `.ir` regens (`8cca3b9`). `src/` now has ZERO `foreign` identifiers
(only comments + the kept token/message remain). Suite green throughout.
- (9.1d) Eliminated the `foreign_expr` AST node — migrated `c_import.zig` auto-synth to
the extern shape, deleted the node + all readers. `refactor` `7ffdc7d`.
- (9.1c) Deleted dead `VarDecl.is_foreign`/`foreign_lib`/`foreign_name` (global `#foreign`
rejects → write-dead); 3 decl.zig readers simplified to `vd.extern_name`/`vd.is_extern`.
Snapshot-neutral; suite green (646/444). `refactor` `cd14794`.
- (9.1b) "foreign symbol already bound" diagnostic + resolveFuncByName panic → "extern
symbol"; intentional 1172 regen. Suite green. `refactor` `b78e7dd`.
- (9.1a) **PHASE 9 STARTED.** 5 collision-free linkage renames (callForeign→callExtern,
marshalForeignArg, dedupeForeignSymbol, foreign_name_map→extern_name_map,
is_foreign_c_api). Snapshot-neutral; suite green. `refactor` `b838f63`. Decision 6
scoped by user: purge `.sx` + docs + our `src/` Zig, keep legitimate hits (SQLite API
names, vendored C, the rejection test/message + hash_foreign token).
- (8.1 cutover) **PHASE 8 COMPLETE.** Parser hard-rejects `#foreign` at all 4 sites
(const/global/fn-body via `self.fail`; runtime-class via `self.failAt` at the caller,
new helper); greens xfail 1176. Deleted obsolete 1174 + 1620, the GATE A→B test +
`lowerSrcToIr` helper; converted the in-source parser test to postfix `extern`;
`extern_export` → `const`. specs.md + readme.md drop `#foreign`. Suite green
(646/444). `feat!` `3811311`.
- (8.0 xfail) Added `1176-diagnostics-foreign-removed.sx` pinning the desired rejection.
RED (still accepted). `test`/xfail `8180faf`.
- (8 pre-cutover) Migrated the 4 multi-file example companions Phase 7 missed
(0729/a+b, 1617/c, 1623/mod). `refactor` `d132aab`.
- (8 pre-cutover) Migrated keyword-neutral diagnostics 1172 (decl→extern, message stays
internal "foreign symbol") + 1228 (→ two foreign-free extern symbols c_abs_one/_two),
intentional snapshot regens reviewed. `refactor` `720556b`.
- (8 pre-cutover) Migrated the 7 identity `ffi-foreign-*` test decls to extern/export
(decls only; comments left for Phase 9.3). `refactor` `2cce6a3`.
- (7.4 stragglers) **PHASE 7 MIGRATABLE WORK COMPLETE.** Migrated 16 fn/global examples
(0415/0602/0603/1024/1025/1605/1607-1609/1611/1616/1619/1622/1628/1635/1636) `#foreign`→
`extern`; 1607/1608/1616 (unmarked) verified by `sx ir` probes. 24-file keep-list remains
by design (deferred to Phase 8). Suite green (647/444). `refactor` `1a8991a`.
- (7.3 14xx) Migrated 13 jni examples (1410-1419/1423/1424/1425). 1417 (all-runtimes) hit
a parse-error trap: a `#(objc|jni)_class`-only regex left `extern` in PREFIX position on
`#jni_interface`/`#objc_protocol`/`#swift_*` lines → fixed with the GENERAL
`#foreign #(\w+)("X") {`→`#$1("X") extern {` rewrite (all such directives accept the
postfix modifier, probed). Kept 1426 (comment-only). Suite green. `refactor` `2888f6f`.
- (7.2 13xx) Migrated 18 obj-c examples (1308/1311-1321/1341-1347): prefix→postfix import
classes + fn markers. Kept identity 1306/1318, comment-only 1332/1348/1349. No 13xx
snapshot asserts on foreign. Suite green. `refactor` `a68f7c2`.
- (7.1 12xx) **PHASE 7 STARTED.** Migrated 12 incidental plain-C examples
(1200/1206/1209-1215/1220/1221/1222) `#foreign`→`extern`; output byte-identical,
empty snapshot diff, corpus-validated. Established the keep-list policy (see Last
completed step): kept 1172/1174/1620/1228 + ffi-foreign-* (1205/1207/1216/1218/1219)
+ comment-only 1223/1229/1230/1231 for Phase 8. Suite green (647/444). `refactor`
`731fb8d`.
- (6.5 gpu) **PHASE 6 COMPLETE.** Migrated `gpu/gles3.sx` + `gpu/metal.sx` (3 sites);
`library/` now `#foreign`-free (`grep -rln '#foreign' library/` → 0). Verified
byte-identical `sx ir` on importers 1610/1606. Suite green (647/444). `refactor`
`32a7628`.
- (6.4 ffi) Migrated `ffi/` objc/objc_block/raylib/sdl3/wasm (~51 sites): fn markers +
objc.sx's 2 import classes (prefix→postfix `extern`). objc/objc_block validated by 50
marked 13xx examples; raylib/ffi-sdl3/wasm by `sx ir` probes pre/post. Empty snapshot
diff; suite green (647/444). `refactor` `666a2e2`.
- (6.3 std) Migrated 11 `std/` modules (~60 sites): cli/core/fmt/fs/log/net.kqueue/
process/socket/thread/time/trace. All fn-decl markers (bare / `libc`|`tlib` LIB ref /
`libc "csym"` rename) → `extern …` + 2 comment mentions; no class forms. Host-corpus-
exercised → empty snapshot diff validates. Suite green (647/444). `refactor` `59f90d2`.
- (6.2 platform) Migrated `platform/` (uikit/android/android_jni/sdl3, ~64 sites):
30 fn `#foreign;`→`extern;`, 34 import classes prefix `#foreign #objc/jni_class`→
postfix `… extern {`, 4 defined `Sx*` objc classes → `… export {`. Behavior-
preserving, empty snapshot diff. Verified byte-identical `sx ir` on uikit importers
1610/1606 + sdl3 probe; android via identical 4-error dedup set (host-only module).
Suite green (647/444). `refactor` `2cd5d7b`. NOTE: these runtime modules aren't in
the marker'd host corpus — verified out-of-band.
- (6.1 sqlite) **PHASE 6 STARTED.** Migrated `vendors/sqlite/sqlite.sx`: 97
`#foreign sqlib "csym";` fn decls → `extern sqlib "csym";` (+ line-9 comment).
`extern_lib` references the `sqlib` `#import c` unit like `#foreign sqlib`; IR
byte-identical, empty snapshot diff, example 1624 stdout unchanged. Suite green
(647/444). `refactor` `410a52e`.
- (5.1 gate annotate) **PHASE 5 COMPLETE.** Annotated the A→B gate header
(`lower.test.zig`) to record that post-Phase-5.0 the fn/global `#foreign` paths
build the same extern-named AST → cases 1/2 are structurally (not coincidentally)
identical; the gate stays as a regression tripwire. Added fn-rename case 2b
(`c_abs` → `"abs"`, `extern_name` axis), IR-identical per a `sx ir` probe.
Test-only, no snapshot churn. Suite green (647/444). `test` `93e7b6f`.
- (5.0 fn-body flip) **PHASE 5.0 PARSER ROUTING COMPLETE.** Flipped the fn-body
`#foreign` parser arm (`parser.zig:~2062`) onto the extern AST (empty-block body +
`extern_export = .extern_` + extern_lib/extern_name); `extern_export` made `var` so
the body arm can route onto it. Updated the parser unit test to assert the extern
shape. Behavior-preserving via the four prereqs; only example 1620's lib-ref message
churned ("#foreign library"→"extern library", Decision 7, hand-edited). Suite green
(647 corpus / 444 unit). `refactor` `6b94bb6`.
- (5.0 prereq plain-free xfail) Added `1230-ffi-extern-same-name-authors` (two flat
authors of `absval` via `extern libc "abs"`; the `extern` twin of `#foreign` 0729).
RED — extern authors wrongly counted as ambiguous (646/1 fail). `test`/xfail `2706521`.
- (5.0 prereq plain-free fix) `isPlainFreeFn`/`isPlainFreeFnDecl` now also exclude
`extern_export == .extern_` (external C symbol, no sx body; name-keyed first-wins like
`#foreign`); `export` stays plain-free. 1230 green (`absval = 7`). Suite green (646/444).
`fix`/green `3c94c14`.
- (5.0 prereq lib-ref xfail) Added `1231-ffi-extern-undeclared-lib` (`extern nosuchunit
"abs"` — bogus lib ref). RED — compiles silently (extern lib ref unvalidated).
`test`/xfail `38c3240`.
- (5.0 prereq lib-ref fix) `checkForeignRefs` (c_import.zig) now reads the lib ref from
either spelling (foreign_expr.library_ref OR extern_lib) and names the surface keyword,
so 1620 (#foreign) is byte-unchanged and 1231 (extern) gets "extern library … not
declared". 1231 green. Suite green (647/444). `fix`/green `ad6aed3`. **ALL FOUR fn-path
prereqs DONE → fn-body flip de-risked; awaiting Decision 7 (interim wording).**
- (5.0 prereq variadic xfail) Added `1229-ffi-extern-cvariadic` (JIT `#source`,
int-sum + double-avg, `extern` C-variadic). Expected snapshot pins the DESIRED
correct output. RED (variadic `extern` slice-packs extras → garbage:
`sum_ints(3,10,20,30)` → 53316585; doubles → 0.0). `test`/xfail `9a2c78d`.
- (5.0 prereq variadic fix) Extended the two C-variadic gates — the `is_variadic`
drop in `declareFunction` (`decl.zig:2097`) and the early-out in
`packVariadicCallArgs` (`pack.zig:302`) — to fire for `extern_export == .extern_`
as well as a `foreign_expr` body. 1229 green (`60` / `2.000000`). Suite green
(645 corpus / 444 unit, 0 failed). `fix`/green `0fdc821`. **BOTH fn-path prereqs
DONE → fn-decl `#foreign` body-marker migration unblocked.**
- (5.0 prereq vis xfail) Added cross-module example `1228-ffi-extern-c-non-transitive`
(main → b → c). Main references c's lib-less `#foreign` + `extern` twins
transitively; expected snapshot pins the DESIRED equivalent C-specific
diagnostic for both. RED (extern twin gets the generic "not visible" wording —
443/444). `test`/xfail commit `717c35d`; the fix greens it.
- (5.0 prereq vis fix) Extended `isVisible(.c_import_bare)` (`decl.zig:2249`) to
switch on the body: a `foreign_expr` body OR an `extern_export == .extern_` decl
with no lib both route to `visibleOverEdges`; a library-bound decl stays
unconditionally visible. 1228 green — both twins emit "C function not visible".
Suite green (644 corpus / 444 unit, 0 failed). `fix`/green commit `7d8ba1a`.
**Deferred prereq (b) CLOSED.** Investigation this session also found
const-with-type is a DEAD parser path (defer per user) and the runtime-class
prefix is already coalesced (no Phase 5.0 change) — see Next step.
- (5.0 global) **PART B STARTED.** Routed the `#foreign` data-global parser path
(`parser.zig:425`) onto the extern-named `VarDecl` (`is_extern`/`extern_lib`/
`extern_name`) — the same AST postfix `extern` builds. Behavior-preserving
(lowering coalesces both at `decl.zig:1119,1127,1141`); zero snapshot churn. Suite
green (444/444 unit, 643 corpus). `refactor` lock, commit `e5ddfbe`. Remaining
Phase 5.0 paths: const-with-type (316), fn-body (2059, needs visibility+variadic
prereqs), runtime-class prefix (1305).
- (init) Plan written; FFI-linkage stream opened.
- (merge) Folded FOREIGN-MIGRATION in as Part B; deleted the split plan + checkpoint.
- (0.0) Added `kw_extern`/`kw_export` tokens + keyword-map entries + LSP keyword
classification + `lex linkage keywords` test. Suite green; no identifier collisions
in the corpus. `lock` commit.
- (0.1) Added `ast.ExternExportModifier` + `FnDecl.extern_export` +
`VarDecl.is_extern`/`extern_name` + `parseOptionalExternExport()` (unconsumed) + 2
parser unit tests. Suite green (443/633). `lock` commit.
- (1.0a) Wired fn-path extern parsing (`parseFnDecl` + both lookahead predicates) +
added `FnDecl.extern_lib`/`extern_name` + `VarDecl.extern_lib` per user feedback
(decision 4 revised: extern carries an optional lib axis). Unconsumed by lowering.
Suite green (443/633). `lock` commit.
- (1.0b) Added `examples/1223-ffi-extern-fn.sx` + hand-authored success snapshots.
RED (634 ran, 1 failed — sema `body produces no value`). `xfail` commit; 1.1 greens it.
- (1.1) Wired extern fn lowering (6 edits in `decl.zig`, all declare-only routing
mirroring `foreign_expr`): `funcWantsImplicitCtx` + `declareFunction` cc +
`lazyLowerFunction`/`lowerFunction`/`lowerFunctionBodyInto` guards. 1223 green;
`declare i32 @abs(i32)` (C ABI, no ctx). Suite green (634/443). `green` commit.
- (1.2a) Added `examples/1224-ffi-extern-fn-rename.sx` (`c_abs :: … extern "abs";`) +
hand-authored success snapshot (`c_abs(-42) = 42`). RED (635 ran, 1 failed — parse
error: `"abs"` after `extern` not yet accepted). `xfail`; 1.2b greens it. (Also
recovered a formatter-clobbered `parser.zig` — see Known issues.)
- (1.2b) `parseFnDecl` parses the optional `[LIB] ["csym"]` tail into
`extern_lib`/`extern_name`; `declareFunction` unifies the rename (foreign c_name OR
extern_name → declare under C name, map sx→C) and extends the dedupe guard to
extern. 1224 green (`c_abs`→`abs`); 1223 unregressed. Suite green (635/443).
`green` commit. extern_lib parsed+stored (lib linking stays the `#library` axis).
- (1.2c) Added `examples/1225-ffi-extern-global.sx` (`__stdinp : *void extern;`,
mirrors `#foreign` global 1205) + success snapshot. RED (636 ran, 1 failed — parse
error: var-decl `extern` not accepted). `xfail`; 1.2d greens it.
- (1.2d) Parser `kw_extern` branch in the var-decl path (`[LIB] ["csym"]` →
`is_extern`/`extern_lib`/`extern_name`) + `registerTopLevelGlobal`/`globalInitValue`
consume `is_extern`. 1225 green (`@__stdinp = external global ptr`). Suite green
(636/443). `green` commit. **PHASE 1 COMPLETE** — `extern` fns + globals fully work.
- (JIT spike) User-requested feasibility investigation of C→sx-by-name in `sx run`
(JIT). Verdict: feasible via `LLVMOrcLLJITAddObjectFile` (C objects into the ORC
JITDylib) — proven by a throwaway spike — but blocked by JITLink MachO TLV handling
(`sx_trace.c`'s `_Thread_local` SIGABRTs without the ORC `MachOPlatform`). Own future
milestone (see Next step). Spike reverted; no commit.
- (2.0) Added the **AOT corpus mode** (`expected/<name>.aot` → `sx build` + execute) to
`corpus_run.test.zig` + retired `tests/run_examples.sh` (verify-step.sh/CLAUDE.md
updated) + `examples/1226-ffi-export-fn.{sx,c,h}` (C calls `sx_square` back). RED (AOT
link fails: `_sx_square` undefined — export not lowered). `xfail`; 2.1 greens it.
- (2.1) Filled export gaps i/ii/iv in `decl.zig` (`.external` linkage + `.c` cc on both
define paths; `funcWantsImplicitCtx` false for any non-`.none` modifier) + force-lower
export fns as roots in `lowerMainAndComptime`. 1226 green via AOT (37/82). Suite green
(637/443). `green` commit.
- (2.2a) Added `examples/1227-ffi-export-fn-rename.sx` (`export "triple_c"`, C calls
`triple_c`). RED (define path emits `@sx_triple`, ignores `extern_name` → C ref
undefined). `xfail`; 2.2b greens it.
- (2.2b) `declareFunction` rename branch fires for `export` (stub under C name +
sx→C in `foreign_name_map`); `lazyLowerFunction` resolves the stub by that C name so
the body promotes into the C-named function (`define @triple_c`). sx-side call sites
resolve via the same map (probe: 5*5→25). 1227 green (22); 1226 unregressed. Suite
green (638/443). `green` commit. **PHASE 2 COMPLETE** — `export` fully works.
- (3.0) Added `examples/1348-ffi-objc-extern-class.sx` (postfix `extern` on `#objc_class`,
new spelling of `#foreign #objc_class`). RED (parser: `expected '{'` after the
directive). Hand-authored green snapshots. `xfail` commit; 3.1 greens it.
- (3.1a) Wired the postfix `extern`/`export` aggregate slot in `parseForeignClassDecl`
(optional modifier between `("X")` and `{`; `var is_foreign_eff` overrides the passed
`is_foreign`, threaded into the `foreign_class_decl` node). No lowering change — reuses
the existing `is_foreign` reference-vs-define path. 1348 green. Suite green (639/443).
`green` commit. **PHASE 3 COMPLETE.**
- (3.1b) Behavior-lock: added `examples/1426-ffi-jni-extern-class.sx` (jni `extern`,
parse-only) + `examples/1349-ffi-objc-export-class.sx` (objc `export` defined class,
`counter: 2`). Both pass against the 3.1a parser change (locked in their own commit per
the cadence rule). Suite green (641/443). `lock` commit. (Note: `-Dupdate-goldens`
newline-normalizes empty stderr → reverted unrelated 1226/1227 churn, kept new stderr
0-byte per repo convention; runner normalizes both.)
- (4.gate) **GATE A→B** — added `lowerSrcToIr` helper + "GATE A→B" test to `lower.test.zig`:
`#foreign` ≡ `extern`/`export` byte-identical printed IR for fn / global / Obj-C class.
Verified live via negative-probe (mutate one side → assertion fails). Behavior-lock; the
equivalence was prototyped first with `sx ir` (LLVM IR byte-identical for all three).
Suite green (641/444). `test` commit.
- (4.diag1) Added `examples/1174-diagnostics-foreign-postfix-conflict.sx` — prefix `#foreign`
+ postfix `export` on an aggregate previously surfaced a confusing internal
"emitObjcDefinedClassAllocImp … compiler bug". `xfail` (golden = clean message) → `green`:
`parseForeignClassDecl` rejects the combo at the postfix keyword (`failFmt`). Suite green.
- (4.docs) `specs.md` (new "`extern`/`export` linkage keywords" subsection after the
`#foreign` FFI docs) + `readme.md` (C Interop section) document the three axes. `docs` commit.
- (4.diag2) Added `examples/1175-diagnostics-extern-export-conflict.sx` — `extern export` on
one fn decl previously gave bare "expected ';'". `xfail` (golden = clean message) → `green`:
`parseFnDecl` rejects a second linkage keyword after `parseOptionalExternExport`. Suite
green (643/444). **PHASE 4 COMPLETE → PART A DONE.**
- (golden-fix) **`-Dupdate-goldens` churn RESOLVED.** Root cause was NOT a code bug:
`writeGolden` always writes `content + "\n"` (empty → canonical 1-byte `\n`, used by 484
of 489 empty goldens). The 5 churning stderr files [1226/1227/1348/1349/1426] were 0-byte
*outliers* (verify trims trailing `\n` so both forms passed, but regen always rewrote them
to 1-byte). Conformed all 5 to the 1-byte form → `-Dupdate-goldens` is now idempotent, no
more churn. (Separately: a flaky `0712-sha256-streaming` >10s timeout appears only under
concurrent `zig build` load — not a real failure; re-run serially.)
## Known issues
- **Workflow hazard (1.2):** an editor format-on-save (or `zig fmt`) clobbered the
working-tree `src/parser.zig` between commits — it reformatted one-liners AND
silently dropped my `hasFnBodyAfterArrow` extern edit, reverting 1223 to a parse
error. Recovered with `git checkout src/parser.zig` (HEAD had the correct,
committed version). **After any Edit-tool change to a file the IDE may have open,
rebuild + run the affected example before trusting the edit.**

View File

@@ -0,0 +1,32 @@
# CHECKPOINT-REIFY — comptime `type_info` / `reify` (async-first foundation, step 3)
Companion to [PLAN-REIFY.md](PLAN-REIFY.md). Update after every step (one step at a
time, per the cadence rule).
## Last completed step
**None — stream just carved.** Design validated (3 codebase reviewers; all five reify
contracts confirmed feasible). No code written yet.
## Current state
- The plan + the five locked contracts exist in `PLAN-REIFY.md`; design-of-record is
`design/execution-evolution-roadmap.md` §7 step 3 + §8.1.
- **Nothing built.** `reify`/`type_info`/`field_type` do not exist in the compiler.
- Confirmed against the source (anchors in the plan): type minting via
`intern`/`internNominal` is programmatic and AST-free; type-fns memoize by mangled
name; enum codegen is fully type-table-driven (zero AST coupling); recursive
forward-declaration (reserve→complete) already exists for source types.
## Next step
**Phase 0.0 (lock):** add `TypeInfo`/`EnumInfo`/`EnumVariant` data types + bodyless
`#builtin` decls for `reify`/`type_info`/`field_type` to `library/modules/std/core.sx`
(parsed, unimplemented → loud bail), with a unit test that the decls parse. Then 0.1
(xfail: `examples/06xx-comptime-reify-enum.sx`) → 0.2 (green: implement `reify(.enum_)`).
## Known issues
None yet.
## Log
- **Stream carved.** Selected as the first async-first foundation: `reify` gates both
channel result types (`RecvResult($T)`) and `race`'s synthesized union, is fully
validated (3 reviewers), and is a self-contained compiler/type-system feature
testable in isolation (`06xx` comptime). Generic-enum syntax dropped in its favor.

167
current/PLAN-ASM.md Normal file
View File

@@ -0,0 +1,167 @@
# sx Inline Assembly — Implementation Plan (ASM stream)
**Design source of truth:** [design/inline-asm-design.md](../design/inline-asm-design.md).
This plan turns that doc's §II.7 stage-map + §II.8 phasing into ordered,
commit-sized, testable steps. Read the design doc first — this file is the
*how/when*, not the *what/why*.
**Surface (decided):**
`asm volatile { "template", "=r" -> T, "r" = expr, clobbers(.cc, .memory) }`
— brace block; `->` output / `=` input; `clobbers(.…)` dot-name list; N `-> Type`
outputs return a tuple; templates are pure AT&T (via LLVM).
**Feasibility (confirmed):** sx links LLVM@19; `src/llvm_api.zig` `@cImport`s
`llvm-c/Core.h`, so `llvm_api.c.*` already exposes `LLVMGetInlineAsm` (9-arg),
`LLVMInlineAsmDialectATT`, `LLVMBuildCall2`, `LLVMAppendModuleInlineAsm`. No shim.
**Relationship to other streams:**
- Phases AE (the inline-asm *expression*) are independent of EXTERN-EXPORT.
- Phase F (global asm) consumes `extern`/`export` to import/expose asm symbols —
do it **after** `PLAN-EXTERN-EXPORT.md` Phase 2.
## Cadence (IMPASSIBLE)
No commit may both add a test AND make it pass. Each feature step is either a
behavior-locking PASSING test, or an xfail test the *next* commit turns green.
Arch-pinned tests live in `examples/16xx-platform-asm-*` and declare their target
via the `expected/<name>.target` sidecar marker (Phase 0). Never regenerate
snapshots while red.
## Phase 0 — corpus target-gating (test-infra prerequisite; no compiler code)
**Why first.** The flagship v1 examples are `x86_64` (syscall-write, divmod,
cpuid) but the dev host is `aarch64`-Darwin, and the corpus runner
([src/corpus_run.test.zig](../src/corpus_run.test.zig)) currently (a) never threads
a per-example `--target` and (b) has no host-arch gate — its only skip is "marker
has no `.sx`". So D.0's `…-syscall-write` markers asserting exit/stdout describe
output the harness *cannot* produce on this host, which would violate the cadence
rule (the "next commit turns it green" can never happen). Phase 0 closes that gap.
It touches **only the runner + two fixtures** — zero compiler code, zero risk to
AE, and unblocks every arch-pinned asm example.
**Marker taxonomy (the cleanup).** The runner currently spreads per-example
*directives* across standalone boolean/value sidecars (`.aot` now, `.target`
proposed, more later). Replace that sprawl with **one optional config file,
`expected/<name>.build`**, holding all build/run directives; the output snapshots
(`.exit` / `.stdout` / `.stderr` / `.ir`) stay separate — they are
machine-regenerated data, not config. `.exit` remains the **test-discovery key**
(every test has one; `.build` is optional).
**`.build` format** — JSON, parsed with `std.json`:
```json
{ "aot": true, "target": "x86_64-linux" }
```
Parse via `std.json.parseFromSlice(BuildConfig, …)` into
`struct { aot: bool = false, target: ?[]const u8 = null }`. Field defaults cover
omitted keys; `std.json`'s default `ignore_unknown_fields = false` makes an
**unknown key a loud `error.UnknownField`** (surfaced as a runner failure, never a
silent ignore — CLAUDE.md no-silent-default rule). Extensible: future `"cpu"`,
`"link"`, `"cwd"` are just new optional struct fields, no new sidecar file and no
custom parser.
**What the directives do:**
1. **`target = <triple|shorthand>`** threads `--target <value>` into every `sx`
invocation for that example (`run` / `build` / `ir``--target` is a global
flag, confirmed [main.zig:39](../src/main.zig#L39)), AND **host-match selects
the mode.** The runner parses the leading `arch` + `os` tokens of the resolved
triple and compares them to `@import("builtin").target` (normalizing
`arm64``aarch64`):
- **match** → *execute* exactly as today (`sx run`, or `aot` build+exec) with
the target threaded, plus the `.ir` diff if an `.ir` snapshot exists. ⇒ an
x86_64 example gives **real end-to-end coverage on an x86_64 CI runner**.
- **mismatch** → **ir-only**: run *only* `sx ir <file> --target <t>`; assert
`.exit` (the ir command's exit), `.ir` (normalized stdout), and `.stderr`
(diagnostics, normally empty). Do **not** run/build/exec; do **not** assert
`.stdout`. An `.ir` snapshot is **required** in ir-only mode — its absence is
a loud runner failure ("arch-pinned <name>: ir-only mode requires an .ir
snapshot"), never a silent pass. Robust even if `sx ir` treats `--target` as
a partial no-op: the `inline_asm` op carries the template + constraint string
verbatim, so the IR snapshot still locks the exact thing §II.11 flags as
silently-miscompiling (the constraint assembler + template rewrite).
2. **`aot`** is the existing JIT-vs-build+exec switch, just relocated from the
standalone `.aot` marker into `.build`.
**Negative compile-error examples need NO `.build`.** `…-missing-volatile`
(no-output-without-`volatile`) is a Sema diagnostic raised before codegen/JIT, so
plain `sx run` reports it identically on any host — it stays a normal example with
no config file.
**update-goldens interaction:** in ir-only mode, `-Dupdate-goldens` writes `.exit`
(ir exit) + `.ir` (+ `.stderr` if non-empty) and skips `.stdout`. Execute mode
(incl. `aot`) is unchanged. `.build` is hand-authored — update-goldens never
writes it.
| Step | Commit | What | Files |
|---|---|---|---|
| 0.0 | lock | Add `BuildConfig` + `std.json` parse of `expected/<name>.build` (unknown-key ⇒ `error.UnknownField`); **migrate** the 2 existing `.aot` markers → `.build` (content `{ "aot": true }`) and delete them; thread `target`'s `--target` into the spawned argv; add `hostMatchesTarget(value) bool` (arch+os token parse, `arm64``aarch64`) gating the **execute** path. Lock with `examples/16xx-platform-target-host.sx` (trivial `main`) + a `.build` `{ "target": "<host arch triple>" }` (still runs+passes) and unit `test`s for the JSON parse + `hostMatchesTarget`. | `src/corpus_run.test.zig`, `examples/expected/1226-*.{aot→build}`, `…/1227-*`, + fixture |
| 0.1 | lock | Implement the **mismatch ⇒ ir-only** branch (skip run/build/exec; assert `.exit`+`.ir`+`.stderr` from `sx ir --target`; require `.ir`). Lock with `examples/16xx-platform-target-cross.sx` (asm-free `() -> i64 { return 0; }`) + `.build` `{ "target": "x86_64-linux" }` + a checked-in `.ir` snapshot — exercises ir-only on the arm64 host. | `src/corpus_run.test.zig` + fixture |
| 0.2 | docs | Update CLAUDE.md §"Test layout"/§"Testing" to document `.build` (format + `aot`/`target` keys) replacing the standalone `.aot` marker prose (lines ~435, ~492). | `CLAUDE.md` |
Both 0.0 and 0.1 are **lock** commits: the runner change and the fixture that
exercises it land together and pass the moment they land (the mechanism works
immediately — nothing is left red), which is the cadence rule's "lock in current
behavior" flavor, not a feature red→green. No asm lowering is gated on either.
**Phase 0 verification:** `zig build test` green; deliberately corrupt the
cross-target `.ir` fixture and confirm the runner reports an IR mismatch (proves
ir-only actually asserts, isn't a no-op); delete it and confirm the
"requires an .ir snapshot" failure fires.
**Estimated runner delta:** ~7090 lines (sidecar read + `--target` argv threading
+ `hostMatchesTarget` + the ir-only branch + update-mode tweak). Within the
"no step > ~500 new lines" rule; well under the read budget.
## Phase A — keyword + AST + parser (parses; no codegen)
| Step | Commit | What | Files |
|---|---|---|---|
| A.0 | lock | add `kw_asm` keyword + map entry; unit lex test `asm → kw_asm` | `src/token.zig`, `src/lexer.zig` + `.test.zig` |
| A.1 | xfail | parse `asm { … }``AsmExpr`/`AsmOperand` in `parsePrimary`; pin an AST/`sx ir` parse snapshot; lowering still `bailDetail("inline asm codegen unimplemented")` | `src/ast.zig` (:85 union arm, :721 structs), `src/parser.zig` (parsePrimary), `src/ir/interp.zig` |
| A.2 | green | parse-shape snapshot lands green; the unimplemented bail is loud + named | — |
## Phase B — sema / typing
| Step | Commit | What | Files |
|---|---|---|---|
| B.0 | xfail | result-type rule (0→`void` / 1→`T` / N→named-or-positional tuple) + checklist (no-output⇒`volatile`, layout, comptime-string template) — pin error messages | `src/ir/expr_typer.zig` |
| B.1 | green | typing + diagnostics implemented; `.unresolved` sentinel on failure (no silent default) | `src/ir/expr_typer.zig`, `src/ir/semantic_diagnostics.zig` |
## Phase C — IR op + lowering
| Step | Commit | What | Files |
|---|---|---|---|
| C.0 | lock | add `inline_asm: InlineAsm` to `Op` + `AsmOperand` (role/name/constraint/operand) + interp `bailDetail` arm; unit tests for the IR shape | `src/ir/inst.zig` (:80), `src/ir/interp.zig` |
| C.1 | xfail→green | `lowerAsmExpr` in `lowerExpr` dispatch — interns template/constraints/clobber-names, lowers input `Ref`s, sets result `TypeId` | `src/ir/lower/expr.zig` |
## Phase D — LLVM emit (single value-output; the core)
| Step | Commit | What | Files |
|---|---|---|---|
| D.0 | xfail | `examples/16xx-platform-asm-syscall-write.sx` + `…-register-read.sx` + `…-no-output-volatile.sx` + `…-missing-volatile.sx` (expected compile error) — all red | examples + `expected/` markers |
| D.1 | green | `emitInlineAsm`: **port `FuncGen.airAssembly`** — constraint-string assembler (outputs `=`/`+`, inputs, `clobbers(.name)``~{name}`), `%[name]``${N}` / `%%` / `%=` template rewriter, `LLVMGetInlineAsm`+`LLVMBuildCall2`, `sideeffect=volatile`, AT&T dialect | `src/ir/emit_llvm.zig` (emitInst dispatch + handler) |
| D.2 | green | lock the template-rewrite + constraint string via an `expected/*.ir` snapshot on `…-template-subst.sx` | examples |
**Phase D verification:** `zig build test`; the syscall example runs on
`x86_64-linux`; IR snapshot matches the design doc's worked `sys_write` lowering.
## Phase E — multi-return tuples + `clobbers(.…)`
| Step | Commit | What | Files |
|---|---|---|---|
| E.0 | xfail | `…-asm-multi-return.sx` (`divmod``(quot,rem)`, `cpuid`→4-tuple) red | examples |
| E.1 | green | N `out_value` → LLVM struct return + `extractvalue i` → sx tuple (named when operands named); `clobbers(.name)` dot-name lowering finalized | `src/ir/emit_llvm.zig`, `src/ir/lower/expr.zig` |
## Phase F — global asm (needs EXTERN-EXPORT Phase 2)
| Step | Commit | What | Files |
|---|---|---|---|
| F.0 | xfail | top-level `asm { … }` decl parsed (reject operands/`volatile`); `…-asm-global.sx` (defines a symbol, imported via `extern`) red | `src/parser.zig`, `src/ast.zig` |
| F.1 | green | lower `asm_global``c.LLVMAppendModuleInlineAsm`; comptime-call guard (dlsym-miss is loud); blocks concatenate in source order | `src/ir/lower/decl.zig`, `src/ir/emit_llvm.zig`, `src/ir/interp.zig` |
## Phase G — later (own steps when scheduled)
`-> @place` write-through + read-write (`"+r" -> @place`) + indirect-memory
(`"=*m"`) outputs · `%=` unique-id · output-to-const rejection · Intel-dialect
opt-in · naked functions (`callconv(.naked)`, coordinate with EXTERN-EXPORT).
## Open decisions (design doc §II.10)
Dialect (AT&T-only v1, recommended) · `volatile` contextual-keyword (recommended)
· brace separator comma (recommended) · `clobbers(.name)` dot-name sugar now →
checked per-arch `Clobber` enum later (Phase 4 of the design doc).
## End-to-end verification (per phase)
`zig build && zig build test`; for arch-pinned examples confirm they run on a
matching host or assert on `sx ir`/`.s` snapshots. After intentional output
changes only: `zig build test -Dupdate-goldens`, then review the diff.

124
current/PLAN-DIST.md Normal file
View File

@@ -0,0 +1,124 @@
# PLAN-DIST — bundle `zig` as sx's hermetic link/libc backend
## Goal
`sx build` produces a native binary by driving a **bundled `zig`**
(`zig cc`) as the linker, so a distributed sx on Linux needs no system
`cc`/lld/libc/CRT. `sx run` (JIT) is unaffected — it never links.
This is the "be like Zig" move: reuse Zig's hermetic toolchain (lld +
crt objects + musl/glibc, all bundled in the `zig` distribution) instead
of building our own lld-in-process + libc-from-source pipeline.
> **Configuration surface** (env vars, flags, resolution order,
> activation truth table, target→ABI map, distribution layout) is
> specified in [../design/bundled-zig-link-backend-design.md](../design/bundled-zig-link-backend-design.md) — the design-of-record
> for how the backend is configured. Keep the two files in sync.
## Locked decisions
1. **Default Linux output ABI = static musl** (`x86_64-linux-musl`,
`-static`). Output runs on ANY Linux with zero deps — the property
that makes Zig binaries portable. glibc/dynamic only via explicit
`--target x86_64-linux-gnu`.
2. **Activation = auto** when a bundled/resolvable `zig` exists AND the
user passed no `--linker`. Falls back to system `cc` otherwise.
3. **Dev uses PATH `zig`** (0.16.0 already installed). Defer copying a
vendored toolchain into `libexec/` until Phase 3 packaging.
## Why `zig cc`, not raw `ld.lld`
`zig cc` is a clang-compatible driver, so it slots into the **existing**
cc-style argv branch in `src/target.zig` almost unchanged, and supplies
lld + crt objects + musl/glibc automatically per `-target`. Driving
`ld.lld` directly would force us to locate/pass crt1.o/crti.o/libc
ourselves — exactly the work we're avoiding.
## Key code anchors (verified)
- Linker selection hook: `TargetConfig.getLinker()``src/target.zig:194-196`
(`self.linker orelse "cc"`).
- Unix `cc`-style link branch: `src/target.zig:524-564` (this is where
the zig backend hooks in; `-o`/`-L`/`-l`/extra objects already pass
through clang-compatibly).
- Exe-relative resolution pattern to mirror for finding zig:
`src/imports.zig:204-227` (`discoverStdlibPaths`, `$SX_STDLIB_PATH`
override + `<exe>/..` candidates).
- `--linker` CLI flag parsing: `src/main.zig:87-90`.
- Emit triple (must agree with link target): `src/ir/emit_llvm.zig`
(`LLVMSetTarget`, ~L246-284).
## Phases
### Phase 0 — Resolve a bundled/host zig
- New `src/zig_backend.zig`: `discoverZig(alloc) -> ?[]const u8`.
Resolution order:
1. `$SX_ZIG` env override.
2. `<exe>/../libexec/zig/zig` (install layout, Phase 3).
3. `<exe>/../../zig-bundle/zig` (dev vendored layout, Phase 3).
4. `zig` on `PATH` (dev fallback — active now).
- Add `SX_DEBUG_ZIG` trace, matching existing `SX_DEBUG_*` hooks.
- No behavior change yet; just resolution + a debug/print hook to confirm.
### Phase 1 — `zig cc` link backend (core change)
- `src/target.zig`: generalize the linker from a single token to a
**driver argv**. Today `getLinker()` returns one string at `argv[0]`;
introduce a `LinkBackend` so the internal backend contributes
`{zigPath, "cc"}` as leading entries.
- In the Unix branch (L524-564), when backend = zig:
- prepend `zig cc`,
- append `-target <mapped triple>`,
- add `-static` for musl,
- everything else (`-o`, `-L`, `-l`, extra objects, extra link flags)
passes through unchanged.
- Add `sxTripleToZig()` mapping (sx shorthand/triple → zig `-target`);
unspecified-on-Linux → `x86_64-linux-musl`.
- Align emit triple: when the zig backend is selected, set the LLVM
module triple in `emit_llvm.zig` to match the link target
(x86_64-linux), so the `.o` links cleanly against musl crt.
### Phase 2 — Activation
- Auto-enable: if `discoverZig()` succeeds and no `--linker` override,
use the zig backend for `sx build`. System `cc` remains the fallback.
- Optional explicit `--self-contained` / `--no-self-contained` to force.
- Confirm `sx run`/JIT path is untouched (no link step).
### Phase 3 — Distribution packaging
- `build.zig`: a `dist` step assembling
- `bin/sx` (built with `-Dstatic-llvm`),
- `libexec/zig/` (vendored zig binary **and its `lib/`**, copied from a
pinned ziglang.org release per host arch),
- `library/` (stdlib),
into a relocatable tarball.
- Pin the zig version (currently 0.16.0).
### Phase 4 — Verify & lock
- Manual first: `sx build hello.sx` (auto zig backend) then `file`/`ldd`
the output → expect "statically linked".
- Honor snapshot-integrity + FFI-cadence rules before adding a corpus
test (host/arch-gated, likely a `.build` sidecar).
## Risks / watch
- **Bundle size**: zig + its `lib/` ≈ 5060 MB.
- **gnu vs musl ABI**: pure codegen objects link fine against musl;
TLS/stack-protector are the only realistic friction. Aligning the emit
triple (Phase 1) covers the common path.
- **macOS/Windows cross** via the same `zig cc -target` is nearly free
after Phase 1, but Apple-SDK linking has caveats — scope to Linux
target first; treat the rest as follow-up.
- **c_import.zig** also shells `cc` for C imports (JIT). Out of scope
here; same backend can absorb it later.
## Status
- [x] Phase 0 — resolve zig (`src/zig_backend.zig`)
- [x] Phase 1 — zig cc link backend (`target.zig` + `emit_llvm` triple normalize)
- [x] Phase 2 — activation (`--self-contained`/`--no-self-contained`; auto on bundled zig)
- [ ] Phase 3 — dist packaging (vendor `zig` into `libexec/`)
- [ ] Phase 4 — verify & lock (manual ✓ macOS/Linux/Windows; corpus test pending runner `--self-contained` support)
Scope landed as **macOS + Linux + Windows** (not Linux-first). See the
"Implementation status" section in
[../design/bundled-zig-link-backend-design.md](../design/bundled-zig-link-backend-design.md)
for what refined the original locked decisions.

View File

@@ -0,0 +1,207 @@
# sx `extern` / `export` + `#foreign` retirement — Plan (FFI-linkage stream)
**One stream, two parts.** **Part A** adds `extern`/`export` (the linkage surface);
**Part B** migrates every `#foreign` onto it and purges `foreign` from the tree.
They are *one* plan: Part B can't start until Part A is a behavior-equivalent
superset of `#foreign`, and Part A isn't "done" until Part B reaches the invariant.
**Design rationale:** [design/inline-asm-design.md](../design/inline-asm-design.md) §II.2
(Deviation 6) + §II.10 #4 + the syntax evaluation.
**Decided syntax**
```sx
name :: (sig) -> Ret [callconv(.x)] [extern | export] [LIB] ["csym"] [;|{…}]; // functions
Name :: #objc_class("X") [extern | export] { … }; // aggregates (mirrors `struct #compiler`)
g : Type extern [LIB] ["csym"]; // extern global
```
- `extern` = import (no body, external linkage, C ABI, no sx ctx) — `#foreign`'s role.
- `export` = define **and** expose (body + external linkage + C ABI + no ctx) — **new**.
- `extern`/`export` imply `callconv(.c)`; write `callconv` only to override.
- Optional `LIB` (a `#library` alias) + `"csym"` rename mirror `#foreign LIB "csym"`,
so `extern` is a true `#foreign` **superset** (Gate A→B): carried on
`extern_lib`/`extern_name`. The `#library` declaration + build-flag linking
mechanism stays a separate axis — `extern` *references* a lib, it doesn't fold
in `#library` itself. (Revises the original "library fully separate" decision 4.)
> **END-STATE INVARIANT (hard requirement).** After this stream, `foreign` appears
> **nowhere** in the live tree — not the `#foreign` surface, and **not** internal
> identifiers. The extern AST is **not** named `foreign_expr`. Enforced by the
> Phase 9.4 grep gate. Scope today: 643 `foreign` lines / ~57 identifiers in `src/`
> + 28 in live docs — most of it the objc/jni **runtime-class** machinery.
**Naming constraint (so we can actually reach the invariant):** introduce
`extern`-named representations only — do **not** reuse or extend
`ForeignExpr`/`foreign_expr`/`VarDecl.is_foreign`. Carry extern/export on a new
`FnDecl.extern_export` modifier with a `;`/`{…}` body (so there is **no** `*_expr`
node for it) + `FnDecl.extern_lib`/`extern_name`; add `VarDecl.is_extern`/
`extern_lib`/`extern_name`. The IR is already extern-named (`Function.is_extern`,
`Builder.declareExtern`).
**Key finding (scopes Part A):** the IR + LLVM emit **already support everything**
`Function.linkage` (`.external/.internal/.private`), `is_extern`, `call_conv`, and a
raw un-mangled symbol name are all emitted by `declareFunction`
(`emit_llvm.zig:1225-1300`). Part A is a **parser + lowering** job, no codegen change.
## Cadence (IMPASSIBLE)
No commit may both add a test AND make it pass (xfail-then-green, or a behavior-lock).
`zig build && zig build test` after every step. Never regenerate snapshots while red.
---
# PART A — add `extern` / `export` (alongside `#foreign`)
## Phase 0 — tokens + parser plumbing
| Step | Commit | What | Files |
|---|---|---|---|
| 0.0 | lock | add `kw_extern`, `kw_export` (Tag enum + `StaticStringMap`, beside `kw_callconv` at `token.zig:45,282`); unit lex test | `src/token.zig` |
| 0.1 | lock | `parseOptionalExternExport()` (mirror `parseOptionalCallConv`, `parser.zig:3669`) + `ast.ExternExportModifier` enum + `FnDecl.extern_export` + `VarDecl.is_extern`/`extern_name` fields; **not yet consumed**; unit AST test | `src/parser.zig`, `src/ast.zig` |
## Phase 1 — `extern` (import; equivalent to lib-less `#foreign`)
| Step | Commit | What | Files |
|---|---|---|---|
| 1.0 | xfail | accept postfix `extern` after the callconv slot (`parser.zig:1950`); `examples/12xx-ffi-extern-fn.sx` extern-binds a libc symbol — red (lowering not wired) | `src/parser.zig` |
| 1.1 | green | lowering: `extern``is_extern`, `.external`, `callconv(.c)`, no ctx — route through `declareExtern` like a lib-less `#foreign` (anchors `decl.zig:1123,387,2110,2113`). Example green | `src/ir/lower/decl.zig` |
| 1.2 | green | optional `extern "csym"` rename + extern-global form `g : T extern;` (`parser.zig:425` path) | `src/parser.zig`, `src/ir/lower/decl.zig` |
## Phase 2 — `export` (define + expose; the NEW capability)
Fills the four export-gap conditions (all in `src/ir/lower/decl.zig`):
| Gap | Anchor | Fix |
|---|---|---|
| (i) linkage forced `.internal` | `:2382`, `:2514` | also `.external` when `extern_export == .export` |
| (ii) C ABI not promoted | `:2110` | also `.c` when `== .export` |
| (iii) no symbol-name override | `emit_llvm.zig:1226` raw name | parse optional `export "csym"`; map in the name map |
| (iv) ctx param not suppressed | `:387` `funcWantsImplicitCtx` | also suppress when `== .export` |
| Step | Commit | What | Files |
|---|---|---|---|
| 2.0 | xfail | multi-file test: an `export fn` called from a companion `.c` caller (same `XXXX-` prefix) — red (still internal) | `examples/12xx-ffi-export-fn.{sx,c}` + `expected/` |
| 2.1 | green | gaps (i),(ii),(iv): `export` ⇒ external + C-ABI + no-ctx on a **defined** fn (uses `beginFunction`, not `declareExtern`) | `src/ir/lower/decl.zig` |
| 2.2 | green | gap (iii): `export "csym"` symbol-name override | `src/parser.zig`, `src/ir/lower/decl.zig` |
## Phase 3 — aggregates (objc / jni runtime classes)
| Step | Commit | What | Files |
|---|---|---|---|
| 3.0 | xfail | `#objc_class("X") extern { … }` (import) + `… export { … }` (define) parse alongside legacy `#foreign #objc_class` | `src/parser.zig` (`tryParseForeignClassPrefix` :1305, `parseForeignClassDecl` :1369) |
| 3.1 | green | map postfix `extern`→reference, `export`→define+register; per-runtime tests (objc, jni) | `src/parser.zig`, `src/ir/lower/decl.zig`, `src/ir/lower/objc_class.zig` |
## Phase 4 — interplay, diagnostics, docs
`extern`+`callconv` stacking/redundancy; reject `extern`+`export` together;
`specs.md` documents `extern`/`export` (the three axes); `#foreign` still documented
until Part B cutover.
> **GATE A→B.** `extern`/`export` are a behavior-equivalent **superset** of
> `#foreign`. Lock with a unit test asserting `#foreign` and `extern` lower to
> identical IR for a sample fn / global / class. Do not start Part B before this.
---
# PART B — migrate `#foreign` → `extern`/`export`, then purge `foreign`
**Inventory (drives the batches):** `#foreign` = 466 uses. ~391 sx-code (308 fns
[207 lib / 196 rename], 75 classes [39 objc / 31 jni], 8 globals) + ~145 example
snapshots. 6 libs (`sqlib`98 `libc`61 `objc`22 `tlib`12 `raylib`7 `clib/pcaplib`3).
Hotspots: `vendors/sqlite`(98), `platform/{android,uikit,android_jni,sdl3}`,
`std/{socket,thread,fs,time}`, `ffi/{objc,raylib}`.
## Phase 5 — `#foreign` becomes an alias for `extern`
| Step | Commit | What | Files |
|---|---|---|---|
| 5.0 | lock | route the `#foreign` parser paths (`parser.zig:316,425,1305,1970`) to build the *same extern-named* AST as `extern`/`export`. Suite green, snapshots unchanged | `src/parser.zig` |
| 5.1 | lock | unit test: `#foreign` and `extern` produce identical IR (fn/global/class) | `src/ir/lower/decl.test.zig` |
## Phase 6 — migrate stdlib (behavior-preserving; snapshot diff must be EMPTY)
One commit per batch; rewrite `#foreign``extern` (fns/globals),
`#foreign #objc_class``#objc_class … extern`, defined classes → `… export`.
| Step | Batch | ~sites |
|---|---|---|
| 6.1 | `library/vendors/sqlite/` | 98 |
| 6.2 | `library/modules/platform/` (uikit/android/android_jni/sdl3) | ~95 |
| 6.3 | `library/modules/std/` (socket/thread/fs/time/process/…) | ~60 |
| 6.4 | `library/modules/ffi/` (objc/raylib/objc_block/…) | ~50 |
| 6.5 | remaining `library/` + `vendors/` | remainder |
## Phase 7 — migrate examples + issues (empty snapshot diff; review every diff)
| Step | Batch |
|---|---|
| 7.1 | `examples/12xx-ffi-*` (plain C) |
| 7.2 | `examples/13xx-ffi-objc-*` |
| 7.3 | `examples/14xx-ffi-jni-*` |
| 7.4 | `issues/*` repros + stragglers |
A non-empty diff ⇒ the alias wasn't behavior-equivalent — stop, fix Phase 5.
## Phase 8 — cutover
| Step | Commit | What |
|---|---|---|
| 8.0 | xfail | `examples/11xx-diagnostics-foreign-removed.sx` expects a "`#foreign` removed; use `extern`/`export`" diagnostic — still accepted (red) |
| 8.1 | green | parser hard-rejects `#foreign` (mirrors the variadic `name: ..T` cutover); `specs.md` drops `#foreign`, documents `extern`/`export` |
## Phase 9 — total `foreign` purge (the invariant)
`foreign` must not appear anywhere in the live tree, surface *or* internal. Each step
a mechanical, behavior-preserving rename commit (snapshots unchanged), small
per-file/subsystem commits — not one sweep.
| Step | What | Identifiers (count → new) |
|---|---|---|
| 9.0 | delete the surface | `hash_foreign`(11) + lexer entry + the 4 parse paths + the alias |
| 9.1 | rename **linkage**`extern*` | `foreign_expr`(25) **eliminated** (folds into modifier) · `is_foreign`(39)→`is_extern` · `foreign_lib`/`foreign_name``extern_*` · `foreign_name_map``extern_name_map` · `callForeign`(8)→`callExtern` · `marshalForeignArg``marshalExternArg` · `is_foreign_c_api`(5)→`is_extern_c_api` · `dedupeForeignSymbol``dedupeExternSymbol` |
| 9.2 | rename **runtime-class** machinery → `runtime*` (decision 5) | `ForeignClassDecl`(65) · `ForeignMethodDecl`(31) · `ForeignClassMember`(20) · `ForeignFieldDecl`(15) · `foreign_class_map`(44) · `current_foreign_class`(34)/`_method` · `foreign_path`(62) · `ForeignRuntime` · `parse/tryParseForeignClass*` · `lowerForeign{Method,Static}Call` · `findForeign{Method,Property}InChain` · `resolveForeign*` · `register*ForeignClass*` · `foreignClass*Type` · `*ForeignRefs` |
| 9.3 | purge **live docs** (28 lines) | `specs.md`/`readme.md`/`CLAUDE.md`: drop `#foreign`, document `extern`/`export`; fix file-roles + FFI/bundling notes |
| 9.4 | **acceptance gate** | `grep -rniE 'foreign' src/ library/ examples/ specs.md readme.md CLAUDE.md`**0** |
---
## Open decisions
*Part A (ratified — recommendations stand):* 1. bare keywords (not `#extern`).
2. aggregate position postfix (`#objc_class(…) extern`, like `struct #compiler`).
3. `extern ⇒ callconv(.c)`. 4. **REVISED** (user, 2026-06-14): `extern` carries an
optional `LIB`+`"csym"` axis (`extern_lib`/`extern_name`), mirroring `#foreign LIB
"csym"`, so it's a true `#foreign` superset (Gate A→B). The `#library` declaration +
build-flag linking mechanism stays separate — `extern` references a lib, doesn't
fold in `#library`. (Was: "library fully separate / not on `extern`".)
*Part B:* 5. runtime-class rename target — **RATIFIED `Runtime*Class*`** (user, 2026-06-14;
it's the object-model axis, not linkage). 6. historical carve-out — **STILL OPEN** (user did
not confirm at the Part A milestone): keep `issues/*.md` (+ design-doc prose) as provenance &
gate only the live tree (recommended) vs purge everything. Confirm 6 before Phase 9.
## Relationship to ASM
`PLAN-ASM.md` Phase F (global asm) consumes `extern` (import the asm symbol) and
`export` (let asm call back into sx) — do it after **Part A Phase 2**.
---
## Kickoff prompt (paste into a fresh session to start Part A)
> Work the FFI-linkage stream per `current/PLAN-EXTERN-EXPORT.md` (+ checkpoint
> `current/CHECKPOINT-EXTERN-EXPORT.md`). First read the plan's header (Decided
> syntax, Naming constraint, Key finding) and Part A; rationale is in
> `design/inline-asm-design.md` §II.2 (Deviation 6) + §II.10 #4.
>
> **This session = Part A, Phases 0 and 1 only** (`extern` works as a bare postfix
> keyword equivalent to a lib-less `#foreign` fn/global binding; `#foreign` stays
> untouched). Do NOT start Phase 2 (`export`) or Part B (migration).
>
> **Cadence (IMPASSIBLE):** no commit may both add a test and make it pass — lock
> behavior with a passing test, or land an xfail the next commit turns green.
> `zig build && zig build test` after every step.
>
> **Naming constraint (hard):** introduce only `extern`-named AST — do NOT reuse or
> extend `ForeignExpr`/`foreign_expr`/`VarDecl.is_foreign`. Use a new
> `FnDecl.extern_export` modifier (body `;` or `{…}`) and `VarDecl.is_extern`/
> `extern_name`. IR is already extern-named (`Function.is_extern`, `declareExtern`).
>
> Steps (commit after each; update the checkpoint each time):
> - 0.0 lock: `kw_extern`/`kw_export` tokens + map entries beside `kw_callconv`
> (`src/token.zig:45,282`) + unit lex test.
> - 0.1 lock: `parseOptionalExternExport()` (mirror `parseOptionalCallConv`,
> `parser.zig:3669`) + `ast.ExternExportModifier` + `FnDecl.extern_export` +
> `VarDecl.is_extern`/`extern_name` (parsed, unconsumed) + unit AST test.
> - 1.0 xfail: accept postfix `extern` after the callconv slot (`parser.zig:1950`);
> add `examples/12xx-ffi-extern-fn.sx` that extern-binds a libc symbol (red).
> - 1.1 green: in `src/ir/lower/decl.zig`, lower `extern` like a lib-less `#foreign`
> import — `is_extern`, `.external`, `callconv(.c)`, no ctx, via `declareExtern`
> (anchors :1123, :387, :2110, :2113). Example goes green.
> - 1.2 green: optional `extern "csym"` rename + extern-global `g : T extern;`
> (`parser.zig:425`).
>
> Stop at end of Phase 1. Verify: suite green; the `extern` libc binding runs;
> `#foreign` still works with no snapshot diffs. If you hit an unrelated compiler
> bug, follow the CLAUDE.md IMPASSIBLE RULE (file an issue, stop).

125
current/PLAN-REIFY.md Normal file
View File

@@ -0,0 +1,125 @@
# PLAN-REIFY — comptime type reflection + construction (`type_info` / `reify`)
## Goal
Add the two comptime metaprogramming builtins — **`type_info($T) -> TypeInfo`**
(reflect a type → data) and **`reify(info: TypeInfo) -> Type`** (construct a *new
nominal type* from data) — plus the sx-lib helpers (`make_enum`, `field_type`,
`RecvResult`/`TryResult`) built over them. This is **step 3 of the async-first
sequence** ([../design/execution-evolution-roadmap.md](../design/execution-evolution-roadmap.md)
§7); it gates channel result types (`RecvResult($T)`) and `race`'s synthesized
tagged-union, and **replaces** a would-be `enum($T)` generic-enum language feature.
> Rationale + the five validated contracts: design doc §7 step 3 + §8.1. The approach
> was grounded by three codebase reviewers — it is a **small extension reusing existing
> machinery**, not net-new architecture.
## Locked design (the five reify contracts — all codebase-validated)
1. **Nominal identity via type-fn memoization.** `RecvResult(i64)` is one `TypeId`
because type-fns dedup by mangled `(fn,args)` name (`generic.zig:1620-1629`) +
reify `findByName`. NOT structural dedup — enums are nominal (`types.zig:1110`).
2. **Functional through codegen.** A reify'd enum has **no backing AST decl**, and
every enum stage is type-table-driven (layout, construct, match+exhaustiveness,
`toLLVMType`, `type_name`/format) — so it flows through **unmodified**.
3. **Validate loudly** at the `intern`/`internNominal` choke point (`types.zig:411-439`).
4. **Comptime-only, JIT-free** — a type-table op in the interpreter; no S1 dependency.
5. **Reference-based self-reference** (`*Self`/`[]Self`) via reserve-placeholder→
complete (`nominal.zig:86/108/120`, `types.zig:442`); **by-value recursion rejected**.
Surface follows the **`#builtin`** pattern of the existing reflection builtins
(`type_of`/`field_count`/`field_name` in `library/modules/std/core.sx`,
`specs.md:2594-2600`) — NOT the BuildOptions compiler-hook registry.
## Key code anchors (verified by review)
- Type minting: `TypeTable.intern` / `internNominal``src/ir/types.zig:411-439`.
- Type-fn instantiation + mangled-name cache — `src/ir/lower/generic.zig:1575-1689`
(cache check `:1620-1629`; register inline-struct result `:1663-1689`).
- Forward-declare reserve (recursive types) — `src/ir/lower/nominal.zig:86/108/120`;
complete a forward-declared type — `src/ir/types.zig:442`.
- Enum codegen (all type-table-driven, the reify target shape): size `types.zig:633-636`;
`resolveVariantIndex` `lower/expr.zig:1159-1177`; match `lower/control_flow.zig:748-945`;
`toLLVMType` `backend/llvm/types.zig:111-154`; `type_name` `types.zig:846-882`.
- Existing reflection builtins to mirror — `core.sx` (`#builtin`) + their interp/lower
handlers (`src/ir/interp.zig` `type_name`/reflection at ~`:1911`).
- Match form — `specs.md:408-424`.
## Cadence (IMPASSIBLE)
No commit may both add a test AND make it pass (xfail-then-green, or a behavior-lock).
`zig build && zig build test` after every step. Never regenerate snapshots while red.
Examples: `06xx` (comptime, deterministic), `11xx` (diagnostics for loud failures).
---
## Phases
### Phase 0 — `reify` of a flat enum (the core)
| Step | Commit | What | Files |
|---|---|---|---|
| 0.0 | lock | `TypeInfo`/`EnumInfo`/`EnumVariant` lib types in `core.sx` (data only); `reify`/`type_info`/`field_type` as bodyless `#builtin` decls (parsed, unimplemented → loud bail). Unit: decls parse. | `library/modules/std/core.sx`, `src/ir/interp.zig` |
| 0.1 | xfail | `examples/06xx-comptime-reify-enum.sx``reify(.enum_(.{variants=[.{name="value",payload=i64},.{name="closed",payload=void}]}))`, construct `.value(3)`, match it. Red (reify unimplemented). | `examples/06xx-*` |
| 0.2 | green | implement `reify(.enum_)` → build `EnumInfo`/`TaggedUnionInfo` `TypeInfo`, `internNominal(info, fresh_nominal_id)`, return `TypeId`. Example green; construct + match work unmodified (Contract 2). | `src/ir/interp.zig`, (`src/ir/types.zig` if a helper is wanted) |
### Phase 1 — type-fn → reify identity
| Step | Commit | What | Files |
|---|---|---|---|
| 1.0 | xfail | `examples/06xx-comptime-reify-typefn-identity.sx``R :: ($T)->Type { reify(...) }`; assert `R(i64)` from two sites is ONE type (assignable/matchable across sites). Red if reify-result not registered by mangled name. | `examples/06xx-*` |
| 1.1 | green | register a reify-returning type-fn's result under the instantiation mangled name (mirror the inline-struct path `generic.zig:1663-1689`). Identity holds (Contract 1). | `src/ir/lower/generic.zig` |
### Phase 2 — `type_info` (reflect) + `field_type`
| Step | Commit | What | Files |
|---|---|---|---|
| 2.0 | xfail | reflect a struct/tuple → read variant/field names + **types** (`field_type($T,i)`). Red. | `examples/06xx-*` |
| 2.1 | green | implement `type_info`/`field_type` over the type table (reuse the `field_count`/`field_name` reflection path). | `src/ir/interp.zig` |
### Phase 3 — `make_enum` + `RecvResult`/`TryResult` (sx lib)
| Step | Commit | What | Files |
|---|---|---|---|
| 3.0 | lock | `make_enum(variants) -> Type` (sx lib over `reify`); `RecvResult($T)`/`TryResult($T)` as type-fns. Behavior-lock: `RecvResult(i64)` constructs + matches. | `library/modules/std/*` |
### Phase 4 — reference-based self-reference
| Step | Commit | What | Files |
|---|---|---|---|
| 4.0 | xfail | recursive enum via `*Self` (tree/list): `reify_rec((self)=> .enum_(... payload = ptr_to(self) ...))`. Red. | `examples/06xx-*` |
| 4.1 | green | `reify_rec` reserve-placeholder→complete (reuse `nominal.zig:86`/`types.zig:442` recursive path). | `src/ir/interp.zig`, `src/ir/types.zig` |
### Phase 5 — validation + loud diagnostics
| Step | Commit | What | Files |
|---|---|---|---|
| 5.0 | xfail | `examples/11xx-diagnostics-reify-*` — dup variant names, non-integer backing, **by-value self-reference** ("infinite size; use `*Self`"). Pin the messages. | `examples/11xx-*` |
| 5.1 | green | validate `TypeInfo` at the `intern`/`internNominal` choke point; emit diagnostics, never a broken type (Contract 3). | `src/ir/interp.zig` / `src/ir/types.zig` |
> `RaceResult` (tuple→tagged-union synthesis) is **not** in this stream — it lands with
> `race` (async cluster), but it consumes exactly the `type_info`+`field_type`+`reify`
> primitives built here.
## Risks / watch
- **Mangled-name plumbing (Phase 1)** is the one real unknown — confirm the type-fn
path registers a *reify-returned* result (not just inline `struct {…}` literals).
Fallback: have `reify` itself name the type by the instantiation key + `findByName`.
- **Self-ref completion (Phase 4)** must reuse the existing recursive-type
reserve→complete path; do not invent a new mutate-after-intern mechanism.
- Keep `reify` **comptime-only**: a `reify` reached at runtime is a hard error.
## Status
- [ ] Phase 0 — `reify` flat enum
- [ ] Phase 1 — type-fn identity
- [ ] Phase 2 — `type_info` + `field_type`
- [ ] Phase 3 — `make_enum` + `RecvResult`/`TryResult`
- [ ] Phase 4 — reference self-reference
- [ ] Phase 5 — validation + diagnostics
## Kickoff prompt (paste into a fresh session)
> Work the REIFY stream per `current/PLAN-REIFY.md` (+ checkpoint
> `current/CHECKPOINT-REIFY.md`). Read the plan header (goal, five locked contracts,
> key anchors) first; rationale is in `design/execution-evolution-roadmap.md` §7 step 3
> + §8.1. **This session = Phase 0 only** (`TypeInfo` lib types + `reify` of a flat
> enum: construct + match). Cadence (IMPASSIBLE): no commit both adds a test and makes
> it pass — lock, then xfail→green. `zig build && zig build test` after every step. If
> you hit an unrelated compiler bug, follow the CLAUDE.md IMPASSIBLE RULE (file an
> issue, stop). Stop at the end of Phase 0; update the checkpoint.

View File

@@ -0,0 +1,384 @@
# Bundled `zig` Link Backend for sx — Design Doc & Proposal
> Status: **core landed (macOS / Linux / Windows).** This is the
> design-of-record for how a distributed sx links native binaries
> hermetically. The phased plan lives in
> [../current/PLAN-DIST.md](../current/PLAN-DIST.md); keep the two in sync.
> User-facing surface is documented in `readme.md` (Cross-Compilation §).
---
## Implementation status (landed)
The core backend is implemented and verified on a macOS host:
| Target | Result | Notes |
|--------|--------|-------|
| `--target linux-musl` | static ELF | `zig cc -target x86_64-linux-musl -static` |
| `--target windows-gnu` | PE32+ | `zig cc -target x86_64-windows-gnu` |
| `--target macos` | Mach-O (runs) | `zig cc -target <arch>-macos`, no `-static` |
What shipped, and where it **refined** the original locked decisions:
- **Scope = macOS + Linux + Windows** (not Linux-first). iOS/Android/wasm keep
their specialized toolchains. (`TargetConfig.zigBackendInScope`.)
- **Auto-activation = a *bundled* zig is found** (a real distribution, or a
pinned `$SX_ZIG`). A `PATH`-only zig is the dev fallback and engages **only**
under `--self-contained` — so native dev/CI builds are never silently
rerouted, across all three OSes. This is the precise meaning of the §5.5
"zig found (B)" column: **B = bundled**. *(Refinement of "auto when zig
found": PATH-zig does not auto-engage; the musl-only auto gating considered
mid-design was dropped in favor of bundled-vs-PATH, which is OS-agnostic.)*
- **No translation table** (per the triple-scheme decision): sx triples are
passed straight to `zig cc`, and `emit_llvm` runs them through
`LLVMNormalizeTargetTriple` so vendor-less zig triples (e.g.
`x86_64-windows-gnu`) land their OS/env in LLVM's canonical positions —
otherwise "windows" sits in the vendor slot and the object silently falls
back to ELF. The one unavoidable exception is **macOS**: the object must be
emitted from Apple's `apple-darwin` triple (LLVM needs it for Mach-O), but
zig's `-target` parser rejects that scheme, so the *linker* triple alone is
the vendor-less `<arch>-macos`. One OS-specific line, not a table.
- **New shorthands:** `linux-musl`, `linux-musl-arm`, `windows-gnu` (zig
scheme). The existing `linux`/`linux-arm` shorthands were also de-vendored
(`x86_64-linux-gnu`, matching the corpus runner's own expander).
Files: `src/zig_backend.zig` (discovery), `src/target.zig`
(`selectZigLinker` / `emitZigLinkArgv` / `zigTargetTriple` / dispatch in
`link`), `src/ir/emit_llvm.zig` (triple normalization), `src/main.zig`
(`--self-contained` / `--no-self-contained` + shorthands).
Not yet done: distribution packaging (Phase 3 — vendoring `zig` into
`libexec/`), and a corpus regression test (needs the runner to thread
`--self-contained`; manual verification only so far).
The sections below are the original proposal; where they say "Linux-first" or
"follow-up" for macOS/Windows, the table above supersedes them.
---
## 0. TL;DR + feasibility
**Problem.** A distributed `sx` compiler can run on a Linux box (static-LLVM
binary + relocatable `library/`), but it cannot *finish a build*: the final
link step shells out to the host's `cc`, and relies on the host's libc + CRT
objects. No `cc`/glibc/SDK on the box → no binary. That is the gap between
"sx runs here" and "sx is a toolchain here."
**Proposal.** Bundle a pinned `zig` binary inside the sx distribution and use
`zig cc` as the link backend for `sx build`. `zig cc` brings its own lld,
CRT objects, and libc (musl or glibc) for the chosen target. Default Linux
output is **statically-linked musl**, which runs on any Linux with zero
dependencies — the property that makes Zig's own output portable.
**Feasibility: high.** The change is contained:
- The linker is selected through a single hook —
`TargetConfig.getLinker()` at `src/target.zig:194-196` — and the final
link argv is built in one place, the Unix `cc`-style branch at
`src/target.zig:524-564`.
- `zig cc` is a clang-compatible driver, so `-o` / `-L` / `-l` / extra
objects pass through that branch unchanged. The backend only has to
prepend `zig cc` and add `-target …` / `-static`.
- Exe-relative resolution (for finding the bundled zig) is already solved
for the stdlib in `src/imports.zig:204-227` and can be mirrored.
- `sx run` is JIT and never links, so it is wholly unaffected.
The cost is a ~5060 MB vendored `zig` (binary + its `lib/`) in the
distribution, and version-pinning discipline.
---
## 1. Motivation & background
### 1.1 Current state
| Concern | Today | File |
|---------|-------|------|
| Compiler binary | Self-containable via `-Dstatic-llvm` (no system LLVM) | `build.zig:9-10,156-162` |
| Stdlib | Relocatable, found relative to the exe | `src/imports.zig:204-227` |
| **Linking** | **Shells to system `cc`** | `src/target.zig:524-564` |
| **libc / CRT** | **Provided by the host `cc` driver implicitly** | (no `-lc`/crt passed) |
So two of three legs of a portable toolchain already stand. The third — the
linker and the libc/CRT it pulls in — is the host dependency this design
removes.
### 1.2 Why this matters for distribution
The goal is to hand someone a tarball and have `sx build app.sx` produce a
working binary on a stock Linux machine — a fresh container, a minimal CI
image, a box without `build-essential`. Today that fails at the link step.
Zig solved exactly this problem for its own users; since sx is *built with*
Zig, the cleanest fix is to stand on Zig's hermetic toolchain rather than
re-implement it.
---
## 2. Goals & non-goals
### Goals
- `sx build` produces a native Linux binary with **no host `cc`/ld/libc/SDK**.
- Default Linux output is **portable** (static musl): runs on any Linux.
- **Zero-config in the common case**: a bundled or PATH `zig` is detected and
used automatically; the operator sets nothing.
- A fully-specified, documented configuration surface (this document) for the
cases that *do* need tuning.
- No regression for existing users: system `cc` remains a fallback, and any
explicit `--linker` still wins.
### Non-goals (this iteration)
- Reimplementing lld in-process or building libc from source (see §7 —
Zig already does both; we reuse it).
- First-class Windows/macOS cross-compilation (nearly free as a follow-up,
but unverified — §11).
- Routing C-import compilation (`src/c_import.zig`, which also shells `cc`)
through the backend.
- Glibc-floor version pinning (`…-gnu.2.28`); exposed only if needed.
---
## 3. How Zig achieves hermetic builds (the model we're borrowing)
Zig's turnkey cross-compilation rests on bundling the two things sx borrows
from the host:
1. **In-process lld.** Zig embeds LLVM's lld (ELF/COFF/Mach-O/wasm) and links
without spawning an external linker.
2. **libc as data.** Zig ships musl *source* (builds `libc.a` + `crt*.o` on
demand, cached → static, no dynamic linker → portable output) and glibc
stubs generated from `.abilist` per version. For Windows it ships mingw
`.def` files and synthesizes import libraries.
`zig cc` exposes all of this behind a clang-compatible driver: `zig cc
-target x86_64-linux-musl -static foo.o -o foo` yields a portable binary on
any host, with nothing installed. **This design consumes that driver rather
than rebuilding its internals** — the whole second column above arrives for
free by vendoring the `zig` binary.
---
## 4. Design overview
`sx build` gains a **link backend** abstraction with two implementations:
- `system_cc` — today's behavior (shell `cc`, host libc).
- `bundled_zig` — shell `<zig> cc -target <triple> [-static] …`.
Selection is automatic (§5.5): if a usable `zig` is discovered and the user
gave no explicit `--linker`, `bundled_zig` is used; otherwise `system_cc`.
The backend plugs into the existing Unix link branch — it contributes the
leading `zig cc` tokens and the `-target`/`-static` flags; the rest of the
argv assembly is unchanged because `zig cc` is clang-compatible.
One supporting change: when `bundled_zig` is active, the triple handed to
LLVM in `src/ir/emit_llvm.zig` is aligned to the link target (`x86_64-linux`)
so the emitted object links cleanly against the selected musl CRT.
---
## 5. Detailed design (the configuration surface)
### 5.1 zig discovery — resolution order
`discoverZig()` (new `src/zig_backend.zig`) returns the first hit:
1. `$SX_ZIG` — explicit override.
2. `<exe_dir>/../libexec/zig/zig`**install layout** (§6).
3. `<exe_dir>/../../zig-bundle/zig`**dev vendored layout** (§6).
4. `zig` on `PATH`**dev fallback** (the only one active today).
`<exe_dir>` is resolved exactly as `src/imports.zig` resolves the stdlib.
If none resolve, behavior depends on activation (§5.5): auto-mode silently
falls back to `system_cc`; `--self-contained` errors.
### 5.2 Environment variables
| Var | Effect | Default |
|-----|--------|---------|
| `SX_ZIG` | Absolute path to the `zig` used as the link backend. Highest-priority discovery source. | unset |
| `ZIG_LIB_DIR` | Path to the bundled zig's `lib/`. Needed **only** if `zig` was relocated away from its `lib/`. In the supported layout (§6) they ship together and zig self-locates — leave unset. | unset |
| `SX_DEBUG_ZIG` | Trace discovery: each candidate path and the chosen one (or "none → cc"). Mirrors `SX_DEBUG_STDLIB`. | unset |
| `SX_DEBUG_LINK` | **Existing.** Prints the full link argv — shows the exact `zig cc …` invocation. | unset |
| `SX_STDLIB_PATH` | **Existing.** Stdlib override; unrelated to linking but noted because a full distribution sets neither and relies on exe-relative discovery for both. | unset |
### 5.3 CLI flags (`sx build`)
| Flag | Effect |
|------|--------|
| `--self-contained` | Force `bundled_zig` ON. If no usable zig is found, **error** — do not silently fall back. |
| `--no-self-contained` | Force `system_cc`. |
| `--linker <cmd>` | **Existing.** Explicit linker; supplying it **disables** auto-activation (user's choice wins). To pin a specific zig, prefer `SX_ZIG` + `--self-contained`. |
| `--target <triple\|shorthand>` | **Existing.** Selects target + ABI (§5.4). With `bundled_zig` active and target unspecified on a Linux host → `x86_64-linux-musl` static. |
| `--sysroot <path>` | **Existing.** Forwarded to the linker; rarely needed with `bundled_zig` (zig brings its own sysroot). |
### 5.4 Target → ABI mapping
The default (no `--target`) deliberately differs from the legacy `linux`
shorthand, because portable static output is the entire point.
| `sx` invocation | zig `-target` | Link mode | Portable? |
|-----------------|---------------|-----------|-----------|
| *(no `--target`, Linux host)* | `x86_64-linux-musl` | `-static` | ✅ any Linux |
| `--target linux-musl` *(new)* | `x86_64-linux-musl` | `-static` | ✅ |
| `--target linux` / `linux-x86` | `x86_64-linux-gnu` | dynamic | ❌ host glibc, versioned |
| `--target linux-arm` | `aarch64-linux-musl` | `-static` | ✅ |
| `--target windows` | `x86_64-windows-gnu` | per zig | follow-up (§11) |
| `--target macos` / `macos-arm` | `aarch64-macos` | per zig | follow-up (§11) |
- A **new** `linux-musl` shorthand is added; the existing `linux` shorthand
keeps its current gnu/dynamic meaning for back-compat.
- The LLVM emit triple is aligned to the link target so the `.o` links
cleanly against the selected libc/CRT (§4).
### 5.5 Activation truth table
`B` = a usable zig was discovered (§5.1). Subcommand = `sx build`.
| `--self-contained` | `--no-self-contained` | `--linker` | zig found (B) | Result |
|:---:|:---:|:---:|:---:|--------|
| — | — | no | yes | **bundled_zig** (auto) |
| — | — | no | no | system `cc` (silent fallback) |
| — | — | yes | * | user's `--linker` |
| yes | — | * | yes | **bundled_zig** (forced) |
| yes | — | * | no | **error**: `--self-contained` but no zig |
| — | yes | * | * | system `cc` (forced off) |
- `--self-contained` + `--linker` together: backend choice goes to
`--self-contained`; treat the literal combination as a usage error
(document, don't guess).
- `sx run` / `sx ir` / `sx asm` never link → backend not consulted.
### 5.6 Emit-triple alignment
`src/ir/emit_llvm.zig` (`LLVMSetTarget`, ~L246-284) currently uses the host
default triple when `--target` is unspecified (on Linux,
`x86_64-unknown-linux-gnu`). When `bundled_zig` is active, set the module
triple to match the link target (`x86_64-linux`) so codegen and the musl CRT
agree. Pure codegen objects are ABI-compatible across gnu/musl; aligning the
triple removes the edge-case risk (TLS model, stack protector) up front.
---
## 6. Distribution layout (packaging)
A relocatable tree; everything resolves relative to `bin/sx`, so the whole
directory moves/untars anywhere with no env vars set:
```
sx-<os>-<arch>/
├── bin/
│ └── sx # built -Dstatic-llvm (no system LLVM dep)
├── libexec/
│ └── zig/
│ ├── zig # pinned zig binary
│ └── lib/ # zig's lib/ (musl/glibc sources, lld data, …)
└── library/ # sx stdlib (existing discovery)
└── modules/…
```
Rules:
- `zig` and its `lib/` **must** ship together under `libexec/zig/` so zig
self-locates `lib/`; splitting them forces `ZIG_LIB_DIR`.
- Pinned zig version: **0.16.0** (matches the build toolchain). Record the
exact version in the release manifest — a mismatched `zig cc` CLI is the
likeliest future breakage.
- Vendor the matching zig release per host os/arch from ziglang.org at
package time.
---
## 7. Alternatives considered
| Alternative | Why not (now) |
|-------------|---------------|
| **In-process lld + bundled musl sysroot** (sx owns the pipeline; no zig) | Requires a custom LLVM build *with* lld — the Homebrew `llvm@19` here ships none (`liblld*.a`, headers, `ld.lld` all absent) — plus a C++ lld shim and per-arch prebuilt musl. Strictly more work for the same user-visible result. The right *eventual* target if we want zero foreign binaries; tracked as a follow-up. |
| **Full Zig-style: build libc from source on demand** | Most flexible (any arch/libc version, no prebuilt blobs) but the most work; only worth it after the in-process-lld path exists. |
| **Document a hard dependency on system `cc`** | Zero engineering, but defeats the goal — the box still needs `build-essential`. Acceptable only as the current fallback, not the distribution story. |
| **Bundle just `ld.lld` + a musl sysroot (no full zig)** | Smaller than a whole zig, but we'd hand-manage crt object selection, dynamic-linker paths, and import libs — i.e. re-derive what `zig cc` already encapsulates. Bundle-size saving doesn't justify the fragility. |
Vendoring `zig` wins on effort-to-result because sx already builds with Zig:
it's a first-party dependency, not a foreign toolchain, and it unlocks
Windows/macOS targets later for nearly free.
---
## 8. Phasing
Detail in [../current/PLAN-DIST.md](../current/PLAN-DIST.md). Summary:
0. **Resolve zig**`discoverZig()` + `SX_DEBUG_ZIG`; PATH fallback only.
1. **Link backend** — generalize the linker to a driver argv; emit
`zig cc -target … -static`; align the emit triple.
2. **Auto activation** — wire the §5.5 truth table; `cc` fallback intact.
3. **Packaging**`build.zig` `dist` step assembling the §6 tree.
4. **Verify & lock**`file`/`ldd` shows "statically linked"; host/arch-gated
corpus test honoring the snapshot-integrity + FFI-cadence rules.
The minimum end-to-end proof is Phases 0+1 against PATH zig.
---
## 9. Open decisions
**Locked:**
- Default Linux ABI = **static musl** (portable output).
- Activation = **auto** when a usable zig is found and no `--linker`.
- Dev uses **PATH zig**; vendoring deferred to Phase 3.
**Still open:**
- Exact spelling of the force flags (`--self-contained` vs e.g.
`--bundled-linker`); name chosen here pending review.
- Whether auto-mode should *warn* on silent `cc` fallback or stay quiet
(leaning quiet, with `SX_DEBUG_ZIG` for diagnosis).
- Whether to gate the Phase-4 corpus test behind a `.build` `target`
sidecar or keep it manual until a Linux CI runner exists.
---
## 10. Risks
- **Bundle size** ≈ 5060 MB (zig + `lib/`). Acceptable for a toolchain;
call it out in release notes.
- **zig CLI drift** across versions — pin hard, record in the manifest;
the most likely future breakage.
- **gnu vs musl ABI** for the emitted object — covered by the emit-triple
alignment (§5.6); TLS/stack-protector are the only realistic friction.
- **Operator confusion**: default-no-target (musl) diverging from the
`linux` shorthand (gnu). Mitigated by the new `linux-musl` shorthand and
explicit documentation (§5.4).
---
## 11. Out of scope / follow-ups
- **Windows / macOS targets** via the same `zig cc -target`: nearly free
after the Linux path, but Apple-SDK and Windows specifics need their own
verification — not documented as supported until tested.
- **`src/c_import.zig`** still shells system `cc` for C imports in JIT mode;
route through the backend later.
- **In-process lld** (alternative in §7) as the eventual zero-foreign-binary
endgame.
---
## Appendix — quick recipes (once implemented)
```sh
# Portable static Linux binary (default when a bundled zig is present):
sx build app.sx -o app
file app # → "ELF 64-bit … statically linked"
# Force the backend; fail loudly if no zig is bundled:
sx build app.sx --self-contained
# Use a specific zig:
SX_ZIG=/opt/zig-0.16.0/zig sx build app.sx --self-contained
# Opt out, use the system toolchain:
sx build app.sx --no-self-contained
# Dynamic glibc instead of static musl:
sx build app.sx --target linux
# Debug discovery + the exact link invocation:
SX_DEBUG_ZIG=1 SX_DEBUG_LINK=1 sx build app.sx
```

View File

@@ -0,0 +1,625 @@
# Execution-Model Evolution — Roadmap (comptime JIT · async · concurrency · hot-reload)
> Status: **exploratory design-of-record.** Captures the forward plan for sx's
> execution model across five interlocking threads. Not yet an active
> `PLAN-*`/`CHECKPOINT-*` stream — this is the shared design the streams would be
> carved from. Cross-platform shipping (the bundled-zig backend + the sx bundler)
> is **already landed**; see [bundled-zig-link-backend-design.md](bundled-zig-link-backend-design.md)
> and [../current/PLAN-DIST.md](../current/PLAN-DIST.md).
---
## 0. The thesis
sx's compiler stays small by pushing capability into **library sx + three general
primitives** (`inline asm`, `extern`/`export`, `atomics`) rather than baking
features into codegen. Concretely:
- **Async is a library, not a language feature** — colorblind, stackful fibers
behind an `Io` interface (Zig-inspired). No function coloring, no
async→state-machine transform. The implementation is pure sx down to a per-arch
inline-asm context switch.
- **Comptime gains a JIT escape hatch** — the interpreter stays the default
(debuggable, portable), but drops to a host-JIT for the one thing it can't
walk (inline asm) and, later, for whole fragments (the bundler).
- **One shared substrate** — a persistent ORC LLJIT + host-target emitter — serves
comptime-asm, the bundler, and JIT-resident hot-reload.
The honest trade is **small *surface*, but each primitive is *deep*** — not "small
compiler." The net-new **compiler** obligations this plan adds (all verified absent
today): **atomics lowering** (N1), **generic enums** `enum($T)`, **`type_info` +
`reify` + `field_type`** (comptime type construction), **`callconv(.naked)`**,
**repointable-`context` codegen** (+ per-fiber stack-limit), the **S1 persistent JIT
spine**, **C1 thunk synthesis**, **comptime-asm lifting** (C3), and (later) the **S2
ORC C++ shim**. Async itself is genuinely a library; the *enabling primitives* are a
major codegen/runtime investment. Already landed: `inline asm` (in flight),
`extern`/`export`, the `!`/`try`/`catch`/`onfail`/`raise` ERR stream, value-level
reflection, the `sx run` ORC LLJIT, and the host-FFI trampolines.
---
## 1. The spine (shared substrate)
| ID | Piece | What | Size |
|----|-------|------|------|
| **S1** | Persistent JIT executor | A long-lived ORC LLJIT + a host-triple `LLVMEmitter` + a compiled-fragment cache, plumbed into the interpreter. Today the LLJIT exists only for `sx run`'s `main` ([target.zig:319](../src/target.zig#L319)); the emitter carries one target machine ([emit_llvm.zig:274](../src/ir/emit_llvm.zig#L274)). | L |
| **S2** | ORC C++ shim | `MachOPlatform::Create` + redirectable/lazy-reexport symbols. The bare `LLVMOrcCreateLLJIT` can't do thread-locals, C constructors, or symbol redefinition — the wall the C-with-sx JIT spike hit (`_Thread_local` SIGABRT; `errors-*` examples crashed). Required by any non-trivial JIT or symbol repoint. | M |
S1/S2 are the spine: built once, consumed by **C1** (the FFI thunks — the main
near-term consumer), **C3**, and (later) **R2**. S1 alone suffices for C1/C3 (bare
calling/asm thunks — no TLS/ctors); S2 is only needed for R2 and JIT-ing C-with-sx.
---
## 2. Comptime / build layer
| ID | Piece | Unblocks | Depends | Size |
|----|-------|----------|---------|------|
| **C1** | **Real comptime FFI — JIT calling-thunks (LLVM = single ABI authority).** Trivial calls (scalar/ptr/string args, single-reg return) keep the existing `host_ffi.zig` trampoline fast-path; everything else (floats, structs-by-value, aggregate returns, >8 args, varargs) synthesizes a per-signature thunk, JIT-compiles it via **S1**, and calls it with an args buffer the interpreter fills by known layout (`type_info`). **LLVM emits the ABI-correct call — the same lowering as runtime codegen — so comptime and runtime FFI share ONE ABI implementation.** Rejected: libffi (foreign 2nd ABI impl), hand-rolled sx+asm (3rd impl + drift risk + needs C3 to run its own asm leaf anyway). | struct/string/slice/float signatures at comptime; full C interop in `#run`; lifts the bundler's API straightjacket; unifies comptime+runtime FFI | S1 (fast-path: none) | L |
| **C2** | **`#compiler``extern` collapse** — BuildOptions hooks become real exported C symbols resolved through C1; `*BuildConfig` threaded via global/handle; delete `.compiler_expr`/`compiler_call`/Registry. | one FFI mechanism, not two | C1 (`extern`/`export` already shipped) | M |
| **C3** | **Comptime asm via host-JIT** — stop bailing on `inline_asm` ([interp.zig:1019](../src/ir/interp.zig#L1019)); lift the block (operand model at [inst.zig:354](../src/ir/inst.zig#L354): inputs/`out_value`/`out_place`/`out_ty`/clobbers) to a host-arch thunk via `LLVMGetInlineAsm`, JIT, call through C1, cache by template+sig. | running asm-containing code at comptime | S1, C1 (+S2 non-trivial) | M |
| **C4** *(DROPPED)* | **JIT-the-bundler****not built** (Decision 6). Interp+C1 is the shipping bundler (I/O-bound, so native speed is moot; C1 closes the only capability gap). Remains an always-available S1 optimization if profiling ever shows the bundler's *own logic* is a hotspot. | — | — | — |
**Residue:** cross-arch comptime asm (C3) can't run on the host — narrows the bail
to the cross-compile case; needs a sharp diagnostic ("asm targets `<arch>`, host
is `<host>`").
---
## 3. Concurrency primitives (atomics + threads)
> **Why this is its own section:** we are doing **multiple OS threads**, so the
> async runtime and any lock-free structure need real atomics. OS threads already
> exist; atomics do not.
| ID | Piece | State | Size |
|----|-------|-------|------|
| **N1** | **Atomics — NET-NEW compiler feature.** Atomic load/store/RMW (`add/sub/and/or/xor/swap` + `fetch_min`/`fetch_max`; no `nand`), `compare_exchange`/`_weak` (→ `?T`, **null = success**), and fences, with orderings (relaxed/acquire/release/acq_rel/seq_cst). LLVM provides all — an **emit** feature, not a runtime library. **Surface LOCKED = `Atomic($T)` wrapper + `Ordering` enum** (not `@atomic_*``@` is address-of in sx). | **lowering absent** — zero LLVM `atomicrmw`/`cmpxchg`/`fence` emission today; some IR/inference scaffolding exists | M |
| **N2** | **OS threads + pthread Mutex/Cond + worker Pool** | **landed** — [std/thread.sx](../library/modules/std/thread.sx) (`pthread_create`/`join`/`detach`, in-place `Mutex`/`Cond`, bounded `Pool`). NOTE: pthread mutex **blocks the OS thread** — it is *not* fiber-aware (it would park every fiber on that thread); fiber-aware sync is N3, built on N1. | — |
| **N3** | **Fiber-aware sync** — mutex / channel / waitgroup that **suspend the fiber**, not the OS thread. Hybrid: atomic fast-path (N1) + fiber-suspend slow-path (A2/A5). Distinct from the pthread primitives in N2. | new library | M |
**Compiler obligation for N1:** the emit must map sx orderings to LLVM's and **not
reorder across atomics/fences**. Comptime is single-threaded, so the interpreter
can treat atomic ops as ordinary ops (seq_cst is trivially satisfied with one
thread) — no interp atomics machinery needed.
**N1 is a prerequisite for M:N scheduling (A5) and N3, and is broadly useful**
(lock-free queues, refcounts, the allocator). It is the load-bearing new primitive
this revision adds.
---
## 4. Async — colorblind, stackful, pure-sx
**Commitment:** no function coloring, no async→state-machine transform. Async is a
capability carried in `context` (like `context.allocator`), not a property of a
function's signature. A function does I/O through `context.io`; whether the call
suspends is decided by the `Io` *implementation*, transparently.
| ID | Piece | Notes | Size |
|----|-------|-------|------|
| **A1** | **`Io` interface + `context.io`** — a protocol/vtable threaded like `Allocator`. `io.async(fn,args) → Future`, `future.await`, cancellation. | leverages protocols + context | M |
| **A2** | **Stackful coroutine runtime — in sx lib, NOT a compiler builtin.** The context-switch is a `callconv(.naked)` sx fn with an inline-asm body (save callee-saved + SP/LR into `*from`, load from `*to`, `ret`); fiber bootstrap + stack alloc (`mmap`+guard via `extern`) also sx. The **compiler's** job is only (a) the general primitives — inline asm, `callconv(.naked)`, atomics — and (b) **fiber-safe codegen**: `context` lowered as a *repointable indirection* (never raw TLS) so the switch can repoint it, and stack-limit guards (if emitted) read from a swappable per-fiber location. Most arch-delicate sx in the tree (must match the platform callee-saved set + the compiler ABI), but it's inspectable sx, not a black box. | per-arch, arch-gated; co-validate vs codegen | M |
| **A3** | **Event-loop `Io` impls** — kqueue / epoll / io_uring drive readiness, then the (now-ready) syscall via C1. Plus a trivial **blocking `Io`**. | pure sx around syscall `extern`s | L |
| **A4** | **Stdlib I/O rework** — fs/socket/process take/use `context.io` instead of raw blocking syscalls, so existing calls participate in async. | mirrors the allocator-threading rule | M |
| **A5** | **Schedulers — M:1 → N×(M:1) → M:N, all sx std-lib `Io` vtables (committed; M:N last, not deferred).** M:1 first (minimal vehicle to validate the colorblind stack; covers I/O-bound). N×(M:1) = first parallel step (per-thread M:1 loops + `std/thread.sx` spawn; shared state uses N1 atomics — expected under parallelism, not a wart). M:N work-stealing last (most machinery: thread-safe steal queues + migration + errno/TLS discipline). All over N1 atomics + the A2 asm context-switch + `extern` syscalls. **pinning** API for thread-affine work (UI main thread, GL context). | see §4.3 | M (M:1) / M (N×M:1) / L (M:N) |
### 4.1 How control enters sx (the colorblind model)
- **sx→sx is ordinary.** The whole call chain lives on the fiber stack; a suspend
at a leaf `io.*` freezes the native stack verbatim. No frame knows it suspended.
**Zero special handling at call boundaries** — that's the point.
- **Three inbound boundaries** where the runtime enters sx:
1. **Task entry** (`io.async(fn)`) — a trampoline starts `fn` on a fresh fiber
stack via the normal calling convention.
2. **Resumption** — a context-switch (asm), *not* a call; sx continues mid-stack.
3. **C callback → sx** — must be `export`/`callconv(.c)`; runs on the event-loop
stack (not a fiber) so it **cannot itself suspend** — it may resume/enqueue a
fiber or run a non-suspending sx fn to completion (leaf-only).
### 4.2 `context` is fiber-local (the key obligation)
`context.io`/`context.allocator`/the `push Context` stack are dynamically scoped.
Fibers time-share OS threads (and **migrate** under M:N), so `context` must travel
**with the fiber** — saved/restored on every context-switch — **never a raw TLS
read.** A spawned task snapshots the spawner's context, then evolves its own
`push Context` stack. This is the CLAUDE.md "capture your owning allocator" rule one
level up: ambient state that outlives a suspension point must be carried by the
fiber.
### 4.3 Threads & the two hazard classes (why atomics)
| Model | Parallelism | Migration | Hazards |
|-------|-------------|-----------|---------|
| **M:1** (1 OS thread) | none | none | cooperative, race-free — simplest |
| **N×(M:1)** (per-thread schedulers, no migration) | yes | none | **data races** on shared state → atomics/locks |
| **M:N** (work-stealing) | yes | yes | data races **+** TLS-migration hazards |
- **Parallelism hazard** (any N>1): shared mutable state races → needs **N1
atomics** + N3 fiber-aware sync. The M:1 "no locks" simplicity is gone.
- **Migration hazard** (M:N only): a fiber that moves threads across a suspend
reads the *wrong* thread's TLS. **`errno` must be captured immediately** after
each syscall; **`context` must be fiber-local** (§4.2) — non-negotiable under M:N.
- **Pinning** (`io.pinToThread()`): some work must stay put — the **UI main
thread** (UIKit/macOS/Android — directly the app targets in §6), OpenGL
current-context, TLS-using FFI. M:N needs a "don't migrate / main-thread-only"
fiber attribute (Go's `LockOSThread`).
### 4.4 Pure-sx boundary
Everything is sx except the irreducible FFI floor: the **asm context-switch**
(per-arch, in `.sx`), **syscall `extern`s** (kernel-implemented, like any libc
binding), and **raw stack memory** (`mmap`). The schedulers, event loops, futures,
cancellation, and sync primitives are ordinary sx. Payoff: **swappable `Io`
vtables** — blocking, io_uring, kqueue, a **mock `Io`** for tests, a
**deterministic-simulation `Io`** (fake clock, scripted readiness) for reproducible
concurrency tests — all libraries.
### 4.5 Comptime async = blocking `Io`
At comptime install the **blocking `Io`**: `io.*` just blocks; no fibers, no
scheduler, no suspend. Same source, different vtable. The interpreter never needs
suspend/resume, and the FFI (C1) needs no async awareness. This is *why* the
colorblind model resolves comptime async for free.
### 4.6 Syntax surface (grounded against the grammar)
All of the concurrency/atomics surface lands on **existing** sx grammar — `enum`
tagged unions + `if x == { case … }` match ([specs.md:364,408](../specs.md#L408)),
first-class **tuples** with named fields ([specs.md:815-852](../specs.md#L815)),
`=>` closures, `struct($T)` generics, `callconv(...)`, and the ERR keywords
(`try`/`catch`/`onfail`/`raise`/`error`). `race`/`async`/`await`/`atomic` are **not
reserved words** ([specs.md:168](../specs.md#L168)), so they stay library
types/methods — no keyword additions. One genuinely-new compiler capability is
required (see end).
**Atomics (N1) — generic wrapper type.**
```sx
Ordering :: enum { relaxed; acquire; release; acq_rel; seq_cst; }
Atomic :: ($T: Type) -> Type #builtin; // atomicity carried by the type
counter : Atomic(i64) = .init(0);
counter.store(0, .relaxed);
n := counter.load(.acquire);
prev := counter.fetch_add(1, .seq_cst); // + fetch_sub/and/or/xor (min/max: open)
old := counter.swap(42, .acq_rel);
got := counter.compare_exchange(old, new, .acq_rel, .acquire); // strong → ?T (null = success)
got2 := counter.compare_exchange_weak(old, new, .acq_rel, .acquire); // may fail spuriously; for retry loops
fence(.seq_cst);
```
- CAS takes **two orderings** (success, failure); failure ordering may not be
`release`/`acq_rel` nor stronger than success — enforce in the compiler.
- Weak vs strong matters on **aarch64** (LL/SC) — weak in a loop is the idiom;
both compile identically on x86.
**Channels (N3) — methods only (no `<-`); `recv` returns a tagged union (not `(v, ok)`).**
```sx
RecvResult :: enum($T: Type) { value: T; closed; } // ordinary generic enum (not the race-synthesized union)
TryResult :: enum($T: Type) { value: T; empty; closed; } // non-blocking: 3 states a bool can't express
ch := Channel(i64).make(16); // capacity; .make() unbuffered
ch.send(v);
if ch.recv() == { case .value: (v) { use(v); } case .closed: { /* drained */ } }
ch.close();
// ergonomic layer: `for ch (v) { … }` consumes until closed, hiding RecvResult
```
**Fiber-aware locks (N3) — explicit lock + `defer` (no guard sugar).**
```sx
m : Mutex;
m.lock(); defer m.unlock();
```
**Futures & spawn (A1).**
```sx
f := context.io.async(worker, arg); // Future(R)
r := f.await(); // suspends this fiber
f.cancel();
d := context.io.timeout(5000); // a Future too — raceable like any other
```
**Pinning (A5) — spawn attribute, accepts a thread handle.**
```sx
PinTarget :: enum { any; main; on: Thread; } // default = .any (may migrate)
f := context.io.async(render, pin = .main);
f := context.io.async(worker, pin = .on(some_thread));
```
**`race` (Zig model — over futures, named tuple in → synthesized tagged-union out).**
The input is a **named tuple** (positional also allowed → `.0`/`.1` tags); the
result is an anonymous tagged union whose variants mirror the tuple's labels, each
payload = that field's `Future(T)` projected to `T`. Losers are **cancelled and
joined** before `race` returns (structured).
```sx
fa := context.io.async(read_a, conn); // Future(A)
fb := context.io.async(read_b, conn); // Future(B)
winner := context.io.race((a: fa, b: fb)); // RaceResult = enum { a: A; b: B }
if winner == {
case .a: (v) { handle_a(v); } // v : A
case .b: (v) { handle_b(v); } // v : B
}
// positional form: race((fa, fb)) → tags .0 / .1
```
The Go-style handler-map and the map literal that propped it up are **dropped**
`race` over futures subsumes select, and cancellation handles the losers.
**Cancellation rides ERR.** A cancelled `io.*` **raises**; the fiber unwinds
through `defer`/`onfail` (`try`/`catch`/`raise` are real keywords). Cancellation is
**cooperative** (observed only at suspend points — every `io.*` is a cancellation
point) and **structured** (`race` joins losers' teardown before returning). No
parallel unwind path — it reuses the error channel.
**Context switch (A2).**
```sx
swap_context :: (from: *Fiber, to: *Fiber) callconv(.naked) {
asm { /* save callee-saved + SP into *from; load from *to; ret */ };
}
```
`callconv(.naked)``callconv(.c)`: **no prologue/epilogue/frame** — required
because a context switch deliberately makes SP-in ≠ SP-out (a `.c` epilogue would
restore from the wrong stack). Body is a single `asm` block; you emit your own
`ret`. Args arrive in ABI registers, read directly from asm.
**One new compiler capability (gates `race`):** *comptime tuple→tagged-union
synthesis.* Reflection today only **reads** types (`field_count`/`field_name`/
`type_of`); `RaceResult(T)` must **construct** an anonymous `enum` from a tuple's
`(label, payload-type)` pairs. Supporting pieces: a `field_type($T, i) -> Type`
reflection accessor (we have value-level `field_value` + `type_of`, but type-only
field projection is missing) and `Future(T) → T` projection (falls out of
generics). This is the generic "derive a sum from a product" — useful beyond
`race`.
---
## 5. Dev loop / hot-reload
| ID | Piece | Notes | Depends | Size |
|----|-------|-------|---------|------|
| **R1** | **Hot-reload (dylib swap)** — host owns `State`+allocator; reloadable module is a `.dylib` with a fixed `export` interface; watch→rebuild→`dlopen`→rebind→`dlclose`. State survives (host-owned). | leans on `export` (shipped); sidesteps S2; native | — | M |
| **R2** | **Hot-reload (JIT-resident)** — program runs under S1's LLJIT; reloadable calls route through ORC indirection stubs, repointed on change. Finer granularity; same spine. | | S1, S2 | L |
| **R3** | **Incremental compilation** — dependency tracking + recompile-only-changed. Perf enabler; coarse per-file v1 suffices first. | | — | L |
**Core rule:** the data that must survive a reload cannot be owned by the code that
reloads. Code/state separation — the CLAUDE.md owning-allocator discipline, one
level up.
**Residue — state migration on layout change:** body-only changes hot-swap;
layout/signature/global-type changes are **detected** (compare new vs running
`State` layout via `types.zig`) and trigger **rebuild+restart**. Migration hooks
(`on_reload(old)→new`) are a hard later item. Design against *silent* corruption.
---
## 6. Cross-platform (mostly landed) — from a macOS laptop
### 6.1 Landed
| Capability | State | Reach from a mac |
|---|---|---|
| `extern`/`export` C linkage | done (replaced `#foreign`) | all targets |
| Bundled-`zig cc` cross-link backend | Phases 02 done; packaging pending | **macOS, Linux(-musl/static), Windows(-gnu)** verified |
| sx-side bundler (`.app`/`.apk`) | done | macOS, iOS sim/device, Android |
| JIT `sx run` (ORC LLJIT) | done | host |
| Target shorthands | done | `macos[-arm]`, `linux[-musl[-arm]]`, `windows[-gnu]`, `ios[-arm]`, `ios-sim[-arm/-x86]`, `android[-arm64/-x86_64]`, `wasm` |
### 6.2 Workflows
```sh
# macOS (native): inner loop is JIT; ship is Mach-O / .app
sx run app.sx
sx build app.sx -o app
sx build app.sx --bundle MyApp.app
# Linux (cross, landed killer feature): static, zero-dep ELF
sx build app.sx --target linux-musl -o app # scp anywhere, runs
# Windows (cross, landed, MinGW path): PE32+
sx build app.sx --target windows-gnu -o app.exe # cf. example 1660 (win32)
# iOS simulator (mac-only host)
sx build app.sx --target ios-sim --bundle App.app
# iOS device — signing threaded via the build program (BuildOptions setters)
# #run { o := build_options(); o.set_bundle_id(...); o.set_codesign_identity(...);
# o.set_provisioning_profile(...); }
sx build build.sx --target ios --bundle App.app
# Android (cross + bundle): javac → d8 → aapt2 → zipalign → apksigner, then adb
sx build app.sx --target android --apk app.apk
```
### 6.3 Where the roadmap lights up cross-platform
- **C1 + C4** → the iOS/Android **bundlers** (orchestrate ~a dozen host tools at
comptime; biggest win; always host-arch so no cross-arch risk).
- **R1/R2 + A1A5** → the **inner dev loop for non-host targets**: push-a-dylib +
remote-trigger-reload over an async laptop↔device channel — a capability that
*doesn't exist today* short of full rebuild+reinstall.
- **A1/A2 colorblind `Io`** → the dev tooling is itself async, and the **same
networking code runs blocking inside the bundler** (`adb push`) and async in the
live session — no coloring.
- **Pinning (A5)** → the UI render fiber pins to the main OS thread on every app
target.
**The single hard constraint the matrix exposes:** cross builds mean target arch ≠
host arch, so **C3's residue bites** — comptime/`#run` code reaching *target-arch*
inline asm can't execute on the mac. Native macOS dev never hits it; every cross
target must gate comptime asm to host-arch (`when host_arch == …`) or get a loud
diagnostic.
---
## 7. Linear build sequence (async-first — no parallel streams)
Single ordered list; deps satisfied at every step. **Async-first** (user-chosen): the
async story needs no JIT spine (syscalls use the existing trampoline FFI; comptime
async = blocking `Io`), so the FFI/JIT cluster comes *after*. C4 is omitted (dropped —
an S1 optimization if ever profiled). Net-new compiler prereqs (per the codebase
grounding) are explicit steps, not buried.
**Foundations — compiler primitives the async story needs (all net-new):**
1. **N1 — Atomics lowering.** IR/inference scaffolding exists; add LLVM
`atomicrmw`/`cmpxchg`/`fence` emission + orderings. Surface = `Atomic($T)` wrapper.
Gates channels/N3 + parallel schedulers.
2. ~~**Generic enums** `enum($T)`~~ **DROPPED.** `RecvResult($T)`/`TryResult($T)` are
**type-fns over `reify`** (step 3), not a new `enum($T)` language feature — and
type-fns (user `($T)->Type` in type position) **already work** (e.g.
[`Make`](../examples/0208-generics-value-param-type-function.sx),
[`Complex`](../examples/0201-generics-generic-struct.sx)). A declarative `enum($T)`
surface, if ever wanted, is later *sugar* desugaring to a type-fn-over-`reify`.
3. **`type_info` + `reify` + `field_type`** — comptime metaprogramming floor. Gates
`race` synthesis **and** channel `RecvResult`/`TryResult` (all type-fns over
`reify`; **generic-enum syntax dropped**). **Validated against the codebase (3
reviewers): a small extension reusing existing machinery throughout — not net-new
architecture.** Five contracts:
1. **Nominal identity via type-fn memoization** — type-fns dedup by mangled
`(fn,args)` name (generic.zig:1620-1629) + reify `findByName`, so `RecvResult(i64)`
is one `TypeId` and the body runs once. (NOT structural dedup — enums are
nominal via `nominal_id`, types.zig:1110.)
2. **Functional through codegen** — layout / construct / match+exhaustiveness /
`toLLVMType` / `type_name`+format are **all type-table-driven, zero AST
coupling**, so a backing-decl-less reify'd enum flows through unmodified.
3. **Validate loudly** at the single `intern`/`internNominal` choke point
(types.zig:411-439): reject dup variants / bad backing / unresolved payloads.
4. **Comptime-only, JIT-free** — a type-table op in the interp; no S1 dependency
(keeps reify, hence channels + `race`, off the JIT critical path).
5. **Reference-based self-reference (v1)**`*Self`/`[]Self` payloads via the
reserve-placeholder→complete path recursive *source* types already use
(nominal.zig:86/108/120, types.zig:442); **by-value recursion rejected** (loud,
infinite size). reify gains a `reify_rec((self) => …)` builder form.
- **Type-minting precedents (7):** monomorphization, protocol vtables, tuples,
vector/array, ptr/slice ctors, FFI stubs, **type-fn instantiation** — all
construct `TypeInfo` programmatically + `intern()`. **Residual = plumbing, not
capability:** name reify-results by the instantiation's mangled name (done for
inline-struct bodies — extend to reify-results) + reify input validation.
4. **`callconv(.naked)`** — extend `CallConv {default, c}` (types.zig:169) + skip
prologue/epilogue lowering. Gates A2.
5. **Repointable-`context` codegen** — lower `context` as a swappable indirection
(never raw TLS) + per-fiber stack-limit. Compiler obligation; gates A2 *and*
cross-fiber `context.io` correctness. (Reviewer note: this is a **prerequisite**
of A2, not a successor.)
**Async runtime — sx lib over the primitives:**
6. **A1 — `Io` interface + `context.io` + `Future` + `cancel()` API.**
7. **A2 — fiber runtime** (naked context-switch asm, bootstrap, `mmap` stacks).
8. **A3 — blocking `Io` → deterministic-sim `Io` (keystone, calibrated) → event-loop `Io`.**
9. **A5·M:1 — single-thread scheduler.**
10. **N3 — fiber-aware sync** (channels/mutex/waitgroup; `recv → RecvResult`).
11. **A6 — Cancellation.** `.canceled` in the `!` channel (model a); per-fiber atomic
flag (N1); every `io.*` a cancellation point; structured cancel-and-join; **masked
during cleanup**.
12. **A4 — stdlib I/O rework** (fs/socket/process onto `context.io`).
13. **A5·N×(M:1)** — first parallel (errno-capture + `context`-fiber-local discipline).
14. **A5·M:N** — work-stealing (steal queues + migration + pinning).
**Then comptime / FFI / JIT cluster:**
15. **S1 — persistent JIT spine** → 16. **C1 — real FFI (LLVM = ABI authority, on S1)**
→ 17. **C2 — `#compiler`→`extern`** → 18. **C3 — comptime asm** (S1 + C1; +S2 if
TLS/ctors).
**Deferred tail:**
19. **S2 — ORC C++ shim** (highest-risk — see §8; macOS `MachOPlatform`; ELF/COFF
unplanned) → 20. **R1 — dylib reload** (shipped `export`) → 21. **R2 —
JIT-resident reload** (S1 + S2; **↔ async live-fiber coupling**, §8) → 22. **R3 —
incremental compilation**.
Hard edges to remember: **C1 depends on S1** (the non-trivial FFI cases); **C3 depends
on C1** (calls through its thunk path); **R1/R2 couple to the async runtime** (can't
hot-swap code with live suspended fibers — runtime + long-lived fibers stay
persistent, only leaf logic reloads).
---
## 8. Irreducible hard problems (detect-and-degrade, don't pretend)
1. **State migration across layout change** (R1/R2) → v1 detects + rebuild/restart;
migration hooks later.
2. **Cross-arch comptime asm** (C3) → can't run on host; narrows the bail + loud
diagnostic; gate to host-arch.
3. **M:N migration hazards** (A5) → errno-capture discipline + fiber-local context
(mandatory), pinning for thread-affine work.
### 8.1 Highest technical risks (from review — ranked, async-first lens)
1. **A2 context-switch correctness** (in the async critical path). Silent stack
corruption, per-arch, **untestable by the deterministic-`Io` harness** (it tests
*scheduling*, not the *switch*); a one-register slip is invisible until it crashes
on the right arch. Couples *library asm* to the *compiler ABI* — ABI drift breaks
it silently later. → needs a dedicated **switch-stress test** (§10).
2. **`reify` → anonymous-tagged-union → match-codegen** (gates `race` + channels).
**DE-RISKED by review** (§7 step 3): all enum stages are type-table-driven with
zero AST coupling, identity is handled by existing type-fn mangled-name memoization,
and forward-declaration for self-ref already exists. Residual is *plumbing*
(name reify-results by mangled name + input validation), not new architecture.
3. **Deterministic-`Io` is the test keystone yet itself uncalibrated** — a buggy
deterministic scheduler yields deterministic-*wrong* stdout that snapshots lock in.
→ calibrate against the blocking `Io` / property-test fixed order (§10).
4. **`context`-fiber-local + errno discipline** (A5 M:N). "Non-negotiable" but
enforced by manual rule, not the compiler; M:1 can't even exercise migration.
5. **S2 ORC shim** (deferred, but highest-risk when reached): only C++ in the tree,
**already failed a spike** (`_Thread_local` SIGABRT), `MachOPlatform` is
macOS-specific — **Linux/Windows JIT-resident reload + non-Mac TLS/ctor JIT have no
named plan**. One "M" box hides a per-OS effort.
6. **C1 args-buffer layout-vs-ABI** — "LLVM emits the call" covers the *call*, not the
interpreter's *buffer pack* from `type_info`. Disagreement on edge layouts
(over-aligned/empty structs, aarch64 small-struct register splitting, `bool`) =
silent comptime corruption. → adversarial layout cases (§10).
---
## 9. Decisions log (all resolved)
**Sequencing — locked:** **async-first** (§7). The async cluster (steps 114)
precedes the FFI/JIT cluster (1518) because async needs no JIT spine. **Cancellation
(A6) = model (a)** — a `.canceled` variant in the **existing `!` error channel** that
`io.*` already returns (I/O is inherently fallible, so `io.*` is already `!`-typed —
the "keep calls clean" argument for the non-local-`raise` model is moot). Reuses
`!`/`try`/`catch`/`onfail`; no new unwind primitive. **Net-new prereq surfaced by
grounding:** `callconv(.naked)` (only `.default`/`.c` today). **Generic enums dropped**
`RecvResult($T)`/`TryResult($T)` are **type-fns over `reify`** (type-fns already work
in type position, e.g. `Make`/`Complex`), so no `enum($T)` feature is needed; `reify`
gains two contracts (deterministic identity + functional-enum output, §7 step 3).
**Locked (see §4.6 for the grounded surface):**
- **N1 atomics surface = generic wrapper `Atomic($T)`** + `Ordering` enum, `.init`,
`compare_exchange`/`_weak` returning `?T` (**null = success** — pinned, opposite of
most priors). (Not `@atomic_*` builtins — `@` is address-of in sx.) **RMW set** =
`add/sub/and/or/xor/swap` + `fetch_min`/`fetch_max` (free from LLVM); **no `nand`**.
- **`race` = over futures** (Zig model), **single named-tuple in** (`race((a: fa, b:
fb))`) → synthesized tagged-union out; Go-style handler-map + map literal
**dropped**. **No `async` spawn-sugar** — always `context.io.async(...)`.
- **Channels** = `send`/`recv` methods (no `<-`); **`recv` returns a tagged union**
`RecvResult($T){ value; closed }` (not `(v, ok)`), `try_recv` → `{ value; empty;
closed }`; optional `for ch (v) {…}` iteration sugar. **locks** = `lock()` + `defer
unlock()` (no guard sugar). `race`/`async`/`await` stay library, not keywords.
- **Comptime type metaprogramming = `type_info` + `reify` builtins only** (Zig
`@typeInfo`/`@Type` model). **Everything else is sx lib** — `make_enum`,
`field_type`, `RaceResult`. `reify` coverage starts at **enum/struct/tuple**, grows
later. `Future($T)` exposes `Value :: T` so `Future(X)→X` is plain member access
(no `type_arg` builtin).
- **C1 FFI engine = LLVM as single ABI authority** — per-signature JIT calling-thunks
via S1 (LLVM emits the ABI-correct call, same as runtime codegen); trampoline
fast-path for trivial calls. **libffi/dyncall + hand-rolled-sx rejected** (2nd/3rd
ABI impl; hand-rolled needs C3 for its own asm leaf anyway). Promotes **S1 to
foundational** (shared by C1, C3).
**Scheduler (Decision 5) — locked:** **M:1 → N×(M:1) → M:N**, all **sx std-lib `Io`
vtables** (compiler only provides N1 atomics + the A2 asm context-switch + `extern`
syscalls). M:1 ships first (validates the colorblind stack, covers I/O-bound);
N×(M:1) is the first parallel step; **M:N is last in sequence but committed — not
deferred.** Data races under parallelism are expected and handled with atomics +
fiber-aware sync — that *is* parallelism, not a wart; M:1's lock-freedom is just a
property of the single-threaded case.
**Deferred, orthogonal additions (Decisions 67) — both addable later without
revisiting anything locked:**
- **C4 (Decision 6) — fully orthogonal; not built now.** Pure deferred optimization
riding S1 (already present for C1/C3): JIT the bundler subgraph instead of
interpreting it. Zero coupling — same bundler sx, same C1 FFI. Apply only if
profiling ever shows the bundler's *own logic* is a hotspot (it's I/O-bound, so
unlikely). Interp+C1 is the shipping bundler.
- **Hot-reload (Decision 7) — deferred; mechanism additive.** Substrate ready: R1
(dylib-swap) needs only shipped `export`; R2 (JIT-resident) needs S1 + the S2 ORC
shim. **R1-vs-R2 chosen at pickup.** One coupling (a design constraint, not a
decision change): you can't hot-swap code with **live suspended fibers** pointing
into the old module — so the async runtime + long-lived fibers stay on the
*persistent* side, only transient **leaf logic** is reloadable (or quiesce fibers
before swap).
---
## 10. Testing & gates
Inherits the project cadence (CLAUDE.md): `zig build && zig build test` after every
step; **xfail-then-green or behavior-lock — no commit both adds a test AND makes it
pass**; never regenerate snapshots while red; corpus = `examples/` + `issues/` with
`.exit`/`.stdout`/`.stderr`/`.ir` snapshots. Per-*step* gates live in the eventual
`PLAN-*` streams; this section is the design-level verification strategy that those
streams must implement.
### 10.1 The async test harness = the deterministic-simulation `Io` (the keystone)
Concurrency is nondeterministic (scheduling/readiness order), which **breaks snapshot
testing** outright. So the **deterministic-sim `Io`** (fixed clock, scripted
readiness, deterministic single-stepping scheduler) is not merely a feature — it is
**the test harness for everything async**. Every concurrency example runs under it →
reproducible stdout → snapshottable. Consequence for sequencing: **build the
deterministic `Io` right after the blocking `Io`** (it's the simplest scheduler after
blocking and it *gates the ability to test* fibers/channels/race/schedulers at all).
The 10 patterns in §4.6-adjacent examples become corpus tests only because they run
under it.
### 10.2 What is NOT snapshot-testable
True parallel **data races** (N×M:1 / M:N) are nondeterministic by construction. They
run under the deterministic `Io` for *correctness* repro, but race-detection needs a
separate **stress harness** (run-N-times / TSan-style), **not** the corpus. Any such
coverage bound must be stated loudly (a `log()`-style note in the harness), never
silently skipped — per the REJECTED-PATTERNS rule against silent gaps.
### 10.3 Arch-sensitive lowering — atomics + context-switch
Atomic orderings lower differently per arch (x86 `lock`-prefix / plain MOV vs aarch64
LL/SC / `ldar`/`stlr`), and the A2 context-switch is per-arch asm. Lock both with the
**existing inline-asm cross-arch sibling pattern**: a `.build` `{"target": "…"}`
sidecar runs **ir-only** on a non-matching host (asserts `.ir` + `.exit` + `.stderr`
from `sx ir --target`) and **end-to-end** on a matching CI runner. So `Atomic`
lowering carries **x86_64 + aarch64 `.ir`** snapshots; the context-switch gets
per-arch run tests on matching runners.
### 10.4 New corpus categories
`17xx` atomics · `18xx` concurrency (fibers/channels/race/async, all under the
deterministic `Io`). Comptime metaprogramming (`type_info`/`reify`) + comptime-asm
extend `06xx`; C1 FFI extends `12xx`; the cross-arch comptime-asm **loud bail** and
the cancellation diagnostics are `11xx`.
### 10.5 Per-piece gates (design level)
| Piece | Locks via |
|---|---|
| **N1 atomics** | unit `emit_llvm.test.zig` (LLVM `atomicrmw`/`cmpxchg`/`fence` + ordering emission); corpus `17xx` single-thread (deterministic); arch-gated `.ir` (x86_64 + aarch64) |
| **type_info / reify** | unit (reflect round-trips; reify'd enum has correct layout/match codegen); corpus `06xx` comptime (deterministic) |
| **C1 FFI** | **behavior-lock** existing trampoline cases first; then xfail→green `12xx` comptime extern with floats / structs-by-value / aggregate (`{ptr,len}`) returns; unit for thunk-synth + args-buffer marshal |
| **S1 spine** | infra — exercised transitively via C1/C3 examples; unit for LLJIT lifecycle + thunk cache |
| **C3 comptime asm** | corpus `06xx` host-arch `#run` asm computes a value; `11xx` diagnostic asserts the cross-arch loud bail |
| **A1/A2 fibers** | unit (scheduler step, fiber bootstrap); context-switch arch-gated run tests; corpus `18xx` under deterministic `Io` |
| **A3/A5 schedulers, channels, race, cancel** | corpus `18xx` (the 10 patterns) under deterministic `Io` → deterministic snapshots; cancellation cleanup (`onfail`/`defer`) asserted via stdout ordering |
### 10.6 Cadence example (atomics, N1)
1. **xfail** — add `examples/17xx-atomics-fetch-add.sx` using `Atomic(i64).fetch_add`; seed the `.exit` marker → **red** (codegen missing). *(test added, not yet passing)*
2. **green** — emit LLVM `atomicrmw add` + ordering; example passes; capture `.stdout` + x86_64/aarch64 `.ir` snapshots; review the diff. *(makes it pass, no new test)*
This satisfies "no commit both adds a test and makes it pass," and every other piece
follows the same xfail→green (or behavior-lock→extend) shape.
### 10.7 Review-surfaced gaps (the high-corruption-risk pieces need *correctness*, not existence, tests)
The §10.5 gates prove things *run*; the §8.1 risks are silent-corruption modes a
run/snapshot test won't catch. Each needs an explicit adversarial gate:
- **A2 context-switch — switch-stress test.** Scribble *every* callee-saved register
+ a stack-canary before suspend; deep/recursive fiber chains; verify all survive
post-resume. Run/snapshot tests don't prove register preservation. (The single
highest-corruption-risk piece, §8.1.1.)
- **Deterministic-`Io` — calibrate the oracle.** Cross-check a handful of cases
against the blocking `Io` and property-test that scheduling order is actually fixed,
*before* trusting it to gate everything async (a deterministic-but-wrong scheduler
snapshots garbage).
- **`context`-fiber-local invariant — named test at the N×M:1/M:N step.** M:1 can't
exercise migration; add a test that forces a fiber to migrate and asserts it reads
*its* `context`/`errno`, not the new thread's.
- **N1 ordering *semantics* are out of snapshot scope — state it loudly.** `.ir`
snapshots prove the *keyword emitted*, not weak-memory correctness (e.g. `relaxed`
where `acquire` was needed ships green). Declare this out-of-scope parallel to
§10.2's race carve-out; lock-free structures need the stress harness.
- **C1 args-buffer — adversarial layout cases.** Over-aligned structs, empty structs,
aarch64 small-struct register splitting, `bool` — a wrong layout that happens to
print right passes a stdout test. Call these out explicitly, not just
"structs-by-value."
- **S2 — has no gate today despite a prior spike failure.** When reached, add a TLS +
C-constructor JIT test (the exact `_Thread_local` SIGABRT case), per host OS.
- **Hot-reload — no row today.** When picked up: state-survival test + the
live-suspended-fiber-into-stale-module hazard (R1/R2).

1031
design/inline-asm-design.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@ context:
`sx_trace_push` call emitted through the normal call lowering.
- **`interp`:** yields the packed `(func_id, span.start)` from its own
execution context as the op's value. The separate `sx_trace_push` call
op consuming it is executed by the interp as a foreign call (via
op consuming it is executed by the interp as an extern call (via
`host_ffi`/dlsym, the same path as any extern), storing the packed value
in the buffer; the comptime `.trace_resolve` resolver later recovers
`file:line:col` from it.
@@ -257,7 +257,7 @@ both the trace path and the DWARF path. Items marked ✅ exist today;
| [`src/ir/emit_llvm.zig`](../src/ir/emit_llvm.zig) | IR→LLVM orchestrator. Owns `LLVMEmitter` + the source map (`setDebugContext`); dispatches the `.trace_frame` op and the DWARF passes to the helpers below |
| [`src/backend/llvm/reflection.zig`](../src/backend/llvm/reflection.zig) | `Reflection`: builds the interned `Frame` table + the tag-name / type-name tables; yields the `.trace_frame` op's value (the `Frame` global's address) — the `sx_trace_push` call itself is emitted by `lower.zig` |
| [`src/backend/llvm/debug.zig`](../src/backend/llvm/debug.zig) | `DebugInfo`: builds all DWARF metadata (compile unit, per-function subprograms, per-instruction `DILocation`) |
| [`src/ir/interp.zig`](../src/ir/interp.zig) | Comptime IR interpreter. The `.trace_frame` op yields a packed `(func_id, span.start)`; the separate `sx_trace_push` call op runs as a foreign call (dlsym); `.trace_resolve` recovers comptime frames |
| [`src/ir/interp.zig`](../src/ir/interp.zig) | Comptime IR interpreter. The `.trace_frame` op yields a packed `(func_id, span.start)`; the separate `sx_trace_push` call op runs as an extern call (dlsym); `.trace_resolve` recovers comptime frames |
| [`src/errors.zig`](../src/errors.zig) | `SourceLoc.compute(source, offset) → {line, col}`; the `import_sources` map type |
| [`src/ir/inst.zig`](../src/ir/inst.zig) | `Inst.span`, `Function.source_file`, the `Op` union (home of the `.trace_frame` op) |
| [`library/vendors/sx_trace_runtime/sx_trace.c`](../library/vendors/sx_trace_runtime/sx_trace.c) | the thread-local ring buffer + `sx_trace_report_unhandled` |
@@ -301,8 +301,8 @@ traces and DWARF can never disagree:
declared lazily by `getTraceFids()` (which sets `needs_trace_runtime`).
3. **Interpreter** (`interp.zig`, same op): pack `(current_func_id,
span.start)` into a `u64` and return it as the op's value. The separate
`sx_trace_push` call op is then executed by the interp as a foreign call
(`callForeign` → `host_ffi.lookupSymbol`/dlsym, the same path as any
`sx_trace_push` call op is then executed by the interp as an extern call
(`callExtern` → `host_ffi.lookupSymbol`/dlsym, the same path as any
extern), storing the packed value in the buffer. The comptime
`.trace_resolve` resolver later turns each packed value back into
`file:line:col` via the IR/source tables.

View File

@@ -22,7 +22,7 @@ is never merged (see `S0.2-…`).
| **E-series selection rules** — own-wins / not-visible / ambiguity / direct-flat (the E1E6a behaviors) | **resolver behavior + regression tests** (the baseline-green corpus is the mirror oracle) | S2 behavior; regressions locked S0 |
| **CP rule** — body-author == layout-author | **keyed by `InstantiationId{template_decl, resolved_args}`** in the fact store | S4 |
| **E6BR routed-signature cases** (the E6BR-1…4 behavioral cells) | **resolver-signature regressions** — the resolver walks every signature reference position; cases live in the resolver-target corpus, flip at S3.9 | S3.9 |
| **FFI `foreign_class_map` consumers + FFI corpus (96 entry trees / 95 active markers)** | parallel `DeclId`s land at S1 (map still the consumer); foreign classes keyed by `DeclId` at S4; runtime names stay **payload strings on facts** | S1 → S4 |
| **FFI `runtime_class_map` consumers + FFI corpus (96 entry trees / 95 active markers)** | parallel `DeclId`s land at S1 (map still the consumer); runtime classes keyed by `DeclId` at S4; runtime names stay **payload strings on facts** | S1 → S4 |
## B. DELETED / TRANSITIONAL — removed in S3/S6
@@ -41,7 +41,7 @@ is never merged (see `S0.2-…`).
## C. Dropped / absorbed / superseded plan items
- **E6c / E6d / E6e** (protocol / foreign / type-fn per-kind identity): **DROPPED as
- **E6c / E6d / E6e** (protocol / runtime-class / type-fn per-kind identity): **DROPPED as
steps.** They become resolver behavior + regression tests — a whole-AST resolver
walks every reference position (annotation, `size_of`, dispatch head, `Self`,
vtable), closing the protocol surface the per-kind patch structurally could not
@@ -50,7 +50,7 @@ is never merged (see `S0.2-…`).
(`namespace_edges``ResolvedRef.namespace` / member).
- **H** (constructor heads): **ABSORBED** into S3 `materializeType` over resolved
generic/protocol/type-fn heads.
- **I** (protocol + foreign selection, loud-on-≥2): **ABSORBED** into S2 selection + S4
- **I** (protocol + runtime-class selection, loud-on-≥2): **ABSORBED** into S2 selection + S4
`DeclId` facts.
- **K** (delete dead readers): **SUPERSEDED** by the S4 `DeclId`-keyed fact store + the
S6 deletions — "just delete the maps" is upgraded to "replace with `DeclId` facts."

437
docs/inline-assembly.md Normal file
View File

@@ -0,0 +1,437 @@
# Inline Assembly in sx
A guide to writing inline assembly in sx — emitting raw target
instructions, wiring values in and out, writing through memory, and
defining whole routines in assembly.
> Looking for the *why* behind the design (how it maps to LLVM, the
> Zig comparison, the emit algorithm)? That lives in
> [inline-asm-design.md](../design/inline-asm-design.md). This page is the
> user-facing how-to.
---
## The mental model
`asm` is an **expression**. It drops to the machine: you write a
template of real instructions, declare which sx values feed registers
going in and which come back out, and the block evaluates to the
output value (or a tuple of them).
```sx
add :: (a: i64, b: i64) -> i64 {
return asm { "add %[out], %[a], %[b]", [out] "=r" -> i64, [a] "r" = a, [b] "r" = b };
}
```
Three things to know up front:
1. **The body is a brace block of comma-separated parts:** the template
string first, then operands, then an optional `clobbers(.…)` clause.
2. **Each operand is tagged by role**, not by position: `-> Type` is a
value output, `= expr` is an input, `-> @place` writes through to
existing storage. The list is flat and order-independent — there are
no positional `:` sections.
3. **The outputs decide the result.** Zero outputs → `void` (and the
block must be `volatile`); one → that type; many → a tuple.
Templates are **AT&T syntax** (lowered through LLVM), **target-specific**,
and **never run at compile time** — see [When it runs](#when-it-runs).
---
## Operands
An operand is `[name]? "constraint" <role>`. The constraint string is
the LLVM/GCC-style constraint; the role marker says what the operand
does.
### Inputs — `= expr`
`= expr` feeds a value in. The constraint picks where it lands:
```sx
[a] "r" = a // any general register
"{rdi}" = fd // pinned to a specific register (x86_64 rdi)
```
### Symbol inputs — `"s" = fn`
A `"s"` input feeds a **function or global symbol** (not a runtime value).
In the template, `%[name]` expands to the symbol's **platform-mangled
name**, so you can branch or call straight to it:
```sx
cb :: (n: i64) -> i64 export "cb" { return n + 1; }
trampoline :: (n: i64) -> i64 {
return asm volatile {
#string ASM
mov x0, %[arg]
bl %[fn] // DIRECT call — `bl _cb` on macOS, `bl cb` on Linux
mov %[res], x0
ASM,
[res] "=r" -> i64,
[arg] "r" = n,
[fn] "s" = cb, // symbol operand
clobbers(.x0, .x30, .memory),
};
}
```
The same `%[fn]` works on **x86_64** — just the branch mnemonic differs:
```sx
return asm volatile {
"call %[fn]", // x86_64 — same portable %[fn]
[ret] "={rax}" -> i64,
"{rdi}" = n,
[fn] "s" = cb,
clobbers(.rcx, .rdx, .rsi, .r8, .r9, .r10, .r11, .memory),
};
```
Two reasons to prefer this over passing a function *pointer* in a plain
`"r"` register and using an indirect `blr`/`call *`:
- **One fewer indirection** — a direct PC-relative branch, no pointer
load into a register, and a predictable (non-indirect) branch.
- **Portable** — `%[fn]` is the same on every target; the backend emits
the correctly-mangled name, so you never hardcode the macOS leading
underscore *or* a per-arch operand modifier.
**How the portability works.** A bare `%[fn]` would render differently
per target — on x86 the symbol prints as `$cb` (an immediate `$`-prefix
that `call` rejects), while aarch64 prints it bare. So for a symbol (`"s"`)
operand the compiler **auto-injects LLVM's `:c` operand modifier** (`%[fn]`
`${N:c}`, "print the constant with no punctuation"). `:c` prints the
plain symbol on every target — equivalent to the GCC `:P`/`%P0` call-target
idiom on x86 (both emit the same `R_X86_64_PLT32` relocation) and a no-op
on aarch64. You can still override it with an explicit `%[fn:X]` if you
ever need a different rendering, but for a call/branch you never should.
The callee needs a stable, externally-linked symbol — i.e. `export`
(which also gives it the C ABI). A plain or `callconv(.c)`-only function
is `internal` and gets dead-code-eliminated, so the symbol won't link.
(A global-scope `asm { … }` routine has no operand list, so it can't use
a symbol operand — it references the literal symbol in its text.)
### Value outputs — `-> Type`
`-> Type` produces a value that becomes (part of) the block's result:
```sx
[out] "=r" -> i64 // result in any register
"={rax}" -> i64 // result pinned to rax
```
### Naming and `%[name]`
Inside the template, `%[name]` refers to an operand by its **effective
name**. An operand pinned to a register is **auto-named after that
register** — `"{rdi}"` is reachable as `%[rdi]`, `"={rax}"` as `%[rax]`
— so an explicit `[name]` is only needed:
- for a register-**class** operand (`"=r"`, `"r"`), which has no register
to name it; or
- to give a pinned operand a name *different* from its register.
Two labels are rejected so names stay unambiguous:
- the **echo form** `[rax] "={rax}"` — the label just repeats the pin, so
drop it (the operand is already `%[rax]`); and
- **duplicate** operand names.
In the template, `%%` is a literal `%`, and `%=` expands to a unique id
(handy for a local label that must differ across inlinings).
### The result type
The number of **value** outputs (`-> Type`) decides the block's type:
| `-> Type` outputs | result | example |
|---|---|---|
| 0 | `void` — must be `volatile` | `asm volatile { "dmb ish" }` |
| 1 | that type `T` | `x := asm { …, "=r" -> i64 }` |
| N | a **tuple**, fields named by each operand's name | `lo, hi := asm { … }` |
With multiple outputs you get real multiple return values — a named
operand becomes a named tuple field:
```sx
// aarch64 — split a value into low/high bytes
split :: (x: u64) -> (lo: u64, hi: u64) {
return asm {
#string ASM
and %[l], %[x], #0xff
lsr %[h], %[x], #8
ASM,
[l] "=r" -> u64, // → .lo (operand 0)
[h] "=r" -> u64, // → .hi (operand 1)
[x] "r" = x,
};
}
lo, hi := split(0x1234); // (0x34, 0x12) = (52, 18)
```
---
## `volatile`
`asm volatile { … }` marks the block as having side effects, so the
optimizer won't move or delete it. It is **required whenever there are
no value outputs** — a result-less, non-volatile asm would be dead code.
```sx
barrier :: () { asm volatile { "dmb ish" }; } // aarch64 full barrier
```
A block with outputs may still be `volatile` when its effects matter
beyond the returned value (e.g. a syscall).
---
## `clobbers(.…)`
`clobbers(.…)` is a dot-name list of registers and flags the asm trashes
that aren't already operands — so the register allocator keeps clear of
them:
```sx
clobbers(.rcx, .r11, .memory) // x86_64 syscall trashes rcx, r11, and memory
clobbers(.cc) // condition flags
```
`.memory` means "this asm reads or writes memory the compiler can't see,"
and `.cc` means "the condition flags are modified."
---
## Writing through memory — `-> @place`
Sometimes the asm should write into existing storage (a local, a struct
field) rather than *return* a value. `-> @place` does that: the place
output does **not** join the result tuple. There are three forms,
distinguished by the constraint.
### Write-through — `= …` constraint
The asm computes a value into a register; sx stores it through the
place's address afterward.
```sx
compute :: () -> i64 {
other : i64 = 0;
main_val := asm volatile {
#string ASM
mov %[m], #5
mov %[o], #37
ASM,
[m] "=r" -> i64, // value output → returned into main_val
[o] "=r" -> @other, // place output → stored through @other
};
return main_val + other; // 5 + 37 = 42
}
```
A value output and one or more place outputs can mix freely; only the
value outputs build the returned tuple.
### Read-write — `+` constraint
A `+` operand is read **and** written: the place's current value is fed
in, the asm updates it in place, and the result is stored back.
```sx
// increment-in-place: x is loaded, the asm adds 1, the result is stored back
bump :: () -> i64 {
x : i64 = 41;
asm volatile { "add %[v], %[v], #1", [v] "+r" -> @x };
return x; // 42
}
```
### Indirect memory — `=*m` constraint
An `=*m` operand passes the place's **address** to the asm, which writes
through it directly (no register round-trip, no return slot):
```sx
// store 42 straight into x's storage
poke :: () -> i64 {
x : i64 = 0;
asm volatile {
#string ASM
mov x9, #42
str x9, %[out]
ASM,
[out] "=*m" -> @x,
clobbers(.x9),
};
return x; // 42
}
```
**The place must be mutable storage.** Taking the address of a scalar
`::` constant has no meaning — a scalar constant folds to its value and
has no storage — so `-> @SOME_CONST` is a compile error:
```
cannot take the address of constant 'SOME_CONST' — a scalar '::'
constant has no storage (use a '=' variable or a local copy)
```
---
## Multi-instruction templates
A single `"…"` string is one fragment. For several instructions, use a
multi-line string literal or sx's **`#string` heredoc**, which is
delivered **verbatim** — no escape processing — so you write assembly
exactly as it should appear:
```sx
serialize :: () {
asm volatile {
#string ASM
mfence
lfence
ASM,
};
}
```
---
## Global (module-scope) assembly
A top-level `asm { … }` block is **global assembly** — template only
(no operands, no `volatile`), emitted as module-level assembly. It is
the place to define a whole routine in assembly. Symbols it defines are
reached from sx with a **lib-less `extern`** declaration:
```sx
asm {
#string ASM
.global _my_add
_my_add:
add x0, x0, x1
ret
ASM,
};
my_add :: (a: i64, b: i64) -> i64 extern;
main :: () -> i64 {
return my_add(40, 2); // 42 — computed by the global-asm routine
}
```
Multiple global blocks concatenate in source order. (Symbol naming
follows the platform convention — a leading underscore on macOS, none
on Linux.)
---
## When it runs
Inline assembly is emitted into the program and runs at **runtime**,
under both execution paths:
- **`sx run` (JIT)** — the module is compiled to an in-memory object
(the integrated assembler assembles your asm, including global blocks),
then run. Both inline and global asm work.
- **`sx build` (AOT)** — same, into a native binary.
It does **not** run at **compile time**. A `#run` (comptime) call into a
global-asm symbol fails loudly:
```sx
COMPUTED :: #run my_add(40, 2); // error: the symbol isn't linked yet at comptime
```
```
comptime extern call: symbol not found via dlsym
```
The comptime interpreter resolves `extern` calls against the host
process; a module-asm symbol only exists once the program is
assembled and linked, so call it at runtime, not in a `#run`.
---
## Cookbook
**Read a register** (no inputs):
```sx
stack_ptr :: () -> u64 {
return asm { "mov %[out], sp", [out] "=r" -> u64 }; // aarch64
}
```
**x86_64 syscall**`write(2)`, with pinned registers and clobbers:
```sx
sys_write :: (fd: i64, buf: *u8, count: i64) -> i64 {
return asm volatile {
"syscall",
[ret] "={rax}" -> i64, // bytes written, in rax
"{rax}" = 1, // SYS_write
"{rdi}" = fd,
"{rsi}" = buf,
"{rdx}" = count,
clobbers(.rcx, .r11, .memory),
};
}
```
**x86_64 divmod** — one instruction, two outputs, returned as a tuple:
```sx
divmod :: (n: u64, d: u64) -> (quot: u64, rem: u64) {
return asm {
"divq %[d]",
[quot] "={rax}" -> u64,
[rem] "={rdx}" -> u64,
"{rax}" = n, "{rdx}" = 0, [d] "r" = d,
clobbers(.cc),
};
}
q, r := divmod(17, 5); // (3, 2)
```
---
## Rules of thumb
- **`asm` yields a value.** Bind it (`x := asm { … }`), `return` it, or
destructure a multi-output tuple (`a, b := asm { … }`). A block with no
value outputs must be `volatile`.
- **Pinned operands name themselves.** `"{rdi}"` is `%[rdi]`; only add
`[name]` for register-class operands or to rename. Don't echo a pin
(`[rax] "={rax}"`).
- **`%%` for a literal percent; `%[name]` for an operand.** Templates are
AT&T.
- **List everything you trash** in `clobbers(.…)` — scratch registers,
`.cc`, and `.memory` if the asm touches memory the compiler can't see.
- **`-> @place` writes storage; pick the form:** `=` (compute then
store), `+` (read-modify-write), `=*m` (write through the address).
The place must be mutable — not a scalar `::` constant.
- **Global `asm { … }`** defines symbols; import them with a lib-less
`extern`. They run under JIT and AOT, but **not** in a `#run`.
- **It's target-specific.** Gate or pick instructions per architecture;
there is no portable instruction set.
---
## See also
- [inline-asm-design.md](../design/inline-asm-design.md) — the design rationale and
LLVM mapping.
- `examples/16xx-platform-asm-*` — the full, runnable example matrix
(basic in/out, tuples, the three `-> @place` forms, global asm, the
x86_64 syscall, and the comptime-boundary guard).
- The "Inline Assembly" section of [readme.md](../readme.md) for a
one-screen overview.
```

21
editors/vscode/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 agra
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
editors/vscode/README.md Normal file
View File

@@ -0,0 +1,25 @@
![sx](cover.png)
# sx for Visual Studio Code
Language support for the [sx programming language](https://git.swipelab.com/lab/sx).
## Features
- **Syntax highlighting** for `.sx` files, including embedded GLSL, SQL, HTML, and JSON blocks.
- **Language server integration** — the extension launches the `sx` binary's language server (`sx lsp`) to provide editor intelligence.
- **Breakpoints** registered for the `sx` language.
## Requirements
The `sx` compiler must be installed and on your `PATH` (or point the extension at it via the setting below). The extension shells out to it for the language server.
## Settings
| Setting | Default | Description |
|---------|---------|-------------|
| `sx.lspPath` | `sx` | Path to the `sx` binary used to start the language server (`sx lsp`). |
## License
[MIT](LICENSE) © agra

BIN
editors/vscode/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
editors/vscode/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,26 @@
"description": "Language support for the sx programming language",
"version": "0.0.1",
"publisher": "swipelab",
"icon": "icon.png",
"galleryBanner": {
"color": "#000000",
"theme": "dark"
},
"repository": {
"type": "git",
"url": "https://git.swipelab.com/lab/sx.git"
},
"homepage": "https://git.swipelab.com/lab/sx",
"bugs": {
"url": "https://git.swipelab.com/lab/sx/issues"
},
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Programming Languages"
],
"license": "MIT",
"activationEvents": [
"onLanguage:sx"
],
@@ -73,13 +87,16 @@
},
"scripts": {
"build": "tsc -p .",
"watch": "tsc -watch -p ."
"watch": "tsc -watch -p .",
"vscode:prepublish": "npm run build",
"package": "vsce package --baseContentUrl https://git.swipelab.com/lab/sx/src/branch/master/editors/vscode --baseImagesUrl https://git.swipelab.com/lab/sx/raw/branch/master/editors/vscode"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/vscode": "^1.75.0",
"@vscode/vsce": "^3.9.2",
"typescript": "^5.0.0"
}
}

Binary file not shown.

View File

@@ -179,7 +179,7 @@
"patterns": [
{
"name": "keyword.other.directive.sx",
"match": "#(?:run|import|insert|builtin|foreign|library)\\b"
"match": "#(?:run|import|insert|builtin|library)\\b"
}
]
},
@@ -193,6 +193,10 @@
"name": "keyword.other.sx",
"match": "\\b(enum|struct)\\b"
},
{
"name": "storage.modifier.sx",
"match": "\\b(extern|export)\\b"
},
{
"name": "keyword.operator.cast.sx",
"match": "\\bxx\\b"

View File

@@ -7,7 +7,7 @@
// trampoline's first read.
//
// The fix lives in `abiCoerceParamTypeEx`: the `string`/`slice` →
// `ptr` collapse only applies to `is_extern` foreign decls (libc
// `ptr` collapse only applies to `is_extern` extern decls (libc
// interop). sx-internal `callconv(.c)` keeps the full slice
// shape, which lands as `[2 x i64]` at the LLVM signature site
// and matches the caller's two-register pass on AArch64.

View File

@@ -0,0 +1,22 @@
// Assigning a struct LITERAL to a named-struct member of a plain `union`.
// `u.b = .{ code = 9 }` types the literal as the union member's struct type
// `S` and stores it — the target type propagates to a union-member lvalue
// exactly as it does to a struct field.
//
// Regression (issue 0133): the literal used to lower as `.unresolved` (the
// target-type path only inspected struct fields, not union members) and trip
// the LLVM-emission tripwire in emitStructInit.
#import "modules/std.sx";
S :: struct { code: i64; }
U :: union { a: i64; b: S; }
main :: () {
u : U = ---;
u.b = .{ code = 9 }; // union member <- struct literal
print("code={}\n", u.b.code); // 9
u.a = 5; // scalar member still works
print("a={}\n", u.a); // 5
}

View File

@@ -0,0 +1,20 @@
// A direct write to a tagged-union (enum-with-payload) variant member is
// rejected: a tagged union is laid out `{ tag, payload }`, and a member write
// would set the payload but leave the tag stale. The variant is set via
// construction (`s = .rect(...)`), which writes both tag and payload.
//
// Regression (issue 0136): `s.rect = .{...}` used to silently store the payload
// only, desyncing the tag so a later `match` took the wrong arm. It now errors.
#import "modules/std.sx";
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
}
main :: () {
s : Shape = .circle(1.0);
s.rect = .{ w = 4.0, h = 2.0 }; // rejected — use `s = .rect(.{...})` instead
print("unreachable: {}\n", s.rect.w);
}

View File

@@ -0,0 +1,22 @@
// A write to a sub-field of a tagged-union variant's payload (`s.rect.w = ...`)
// is NOT rejected: the immediate object is the payload struct, so it mutates a
// field of the already-active variant in place and leaves the tag alone. This
// pins the scope of issue 0136's guard — only a WHOLE-variant member write
// (`s.rect = ...`) is rejected; nested sub-field writes keep working.
#import "modules/std.sx";
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
}
main :: () {
s : Shape = .rect(.{ w = 1.0, h = 2.0 });
s.rect.w = 9.0; // nested sub-field write — allowed
r := s.rect;
print("w={} h={}\n", r.w, r.h); // 9 2
s = .circle(3.5); // construction reassign — allowed
print("c={}\n", s.circle); // 3.5
}

View File

@@ -193,12 +193,12 @@ sm_first :: (a: i32, b: i32) -> (i32, !) {
return v;
}
// --- Foreign function binding ---
// --- Extern function binding ---
// --- Foreign function binding ---
// --- Extern function binding ---
libc :: #library "c";
c_abs :: (n: i32) -> i32 #foreign libc "abs";
c_abs :: (n: i32) -> i32 extern libc "abs";
// --- Protocol declarations (Phase 1: static dispatch only) ---

View File

@@ -0,0 +1,44 @@
// Protocol method signatures resolve their param/return type NAMES in the
// protocol's OWN declaring module (own-wins visibility), so a bare type name
// that collides with a same-name namespaced import binds to the local author.
//
// Here the user's `Event` enum shares its name with the stdlib
// `std/event.sx` `Event :: struct` (pulled in, namespaced as `event`, by
// `#import "modules/std.sx"`). `Plat.one_event` returns the user's `Event`;
// `ev := g_plat.one_event()` infers that type, so the `case .key_up:(e)`
// payload binds a `KeyData` and `.escape` resolves against `Keycode`.
//
// Regression (issue 0132): `registerProtocolDecl` used to resolve method
// signature types through the flat, visibility-UNAWARE `type_bridge`
// resolver, which picked the stdlib `event.Event` struct instead — typing
// `ev` as a fieldless struct, binding `.unresolved`, and emitting
// "enum literal '.escape' has no destination type to resolve against". The
// fix pins resolution to `pd.source_file`, mirroring the parameterized-
// protocol and concrete-fn signature paths.
//
// Expect: prints `escape!`, exit 0.
#import "modules/std.sx";
Keycode :: enum { unknown; escape; enter; }
KeyData :: struct { key: Keycode; }
Event :: enum { none; key_up: KeyData; }
Plat :: protocol { one_event :: () -> Event; }
Impl :: struct { dummy: i64; }
impl Plat for Impl {
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
}
main :: () {
impl : Impl = .{ dummy = 0 };
g_plat : Plat = xx @impl;
ev := g_plat.one_event(); // type INFERRED from protocol return
if ev == {
case .key_up: (e) {
// `e` is KeyData (payload of the user's Event), `.escape` a Keycode
if e.key == .escape { print("escape!\n"); }
}
}
}

View File

@@ -0,0 +1,27 @@
// `xx <pack>[i]` erased to a protocol-typed local.
//
// Erasing a single comptime-pack element to a protocol scalar routes through
// buildProtocolErasure. A pack index is a comptime rvalue (a pack has no
// runtime storage — `sources[i]` resolves to the call-site arg, which only
// gets storage when lowered as a value), so the erasure must heap-copy the
// materialized element rather than take its address.
//
// Regression (issue 0135): `xx sources[0]` used to lower the bare pack as a
// value and error with "pack 'sources' has no runtime value".
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
first :: (..sources: VL) -> i64 {
x : VL(i64) = xx sources[0]; // erase element 0 to VL(i64)
return x.get();
}
main :: () -> i32 {
print("{}\n", first(IntCell.{ v = 7 })); // 7
print("{}\n", first(IntCell.{ v = 42 }, IntCell.{ v = 99 })); // 42 (element 0)
0
}

View File

@@ -0,0 +1,25 @@
// Erase two DISTINCT comptime-pack elements to protocol locals — each gets
// its own heap copy and resolves to its OWN concrete type's method (IntCell.get
// vs Doubler.get), proving the per-element erasure picks the right vtable.
//
// Regression (issue 0135): single-element `xx pack[i]` erasure to a protocol
// scalar was unsupported (the bare pack lowered as a value and errored).
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
Doubler :: struct { n: i64; }
impl VL(i64) for Doubler { get :: (self: *Doubler) -> i64 => self.n * 2; }
sum_two :: (..sources: VL) -> i64 {
a : VL(i64) = xx sources[0]; // erase element 0
b : VL(i64) = xx sources[1]; // erase element 1
return a.get() + b.get();
}
main :: () -> i32 {
print("{}\n", sum_two(IntCell.{ v = 10 }, Doubler.{ n = 16 })); // 10 + (16*2) = 42
0
}

View File

@@ -7,8 +7,8 @@
#import "modules/build.sx";
libc :: #library "c";
popen :: (cmd: [:0]u8, mode: [:0]u8) -> *void #foreign libc;
puts :: (s: [:0]u8) -> i32 #foreign libc;
popen :: (cmd: [:0]u8, mode: [:0]u8) -> *void extern libc;
puts :: (s: [:0]u8) -> i32 extern libc;
R :: struct { x: i32; }

View File

@@ -8,7 +8,7 @@
#import "modules/std.sx";
#import "modules/build.sx";
puts :: (s: [:0]u8) -> i32 #foreign libc;
puts :: (s: [:0]u8) -> i32 extern libc;
cb :: () -> bool {
a := format("{}", "x");

View File

@@ -1,4 +1,4 @@
// Real OS-argv accessor from `modules/std/cli.sx` (#foreign _NSGetArgv).
// Real OS-argv accessor from `modules/std/cli.sx` (extern _NSGetArgv).
//
// Only DETERMINISTIC structural invariants are asserted — the actual arg
// contents depend on how the test is invoked (under `sx run` the process

View File

@@ -1,12 +1,12 @@
// fix-0102c (issue 0102) F3 regression: two flat FILE imports each `#foreign`
// fix-0102c (issue 0102) F3 regression: two flat FILE imports each `extern`
// the SAME libc symbol under the SAME sx name `absval`. The bare-call resolver
// must NOT count `#foreign` (non-plain) authors when deciding ambiguity — it
// filters them out, returns "no rerouting", and the existing first-wins foreign
// dispatch binds the call. A same-name foreign collision therefore compiles and
// must NOT count `extern` (non-plain) authors when deciding ambiguity — it
// filters them out, returns "no rerouting", and the existing first-wins extern
// dispatch binds the call. A same-name extern collision therefore compiles and
// runs (master behavior), it does NOT error as ambiguous.
#import "modules/std.sx";
#import "0729-modules-flat-same-name-foreign/a.sx";
#import "0729-modules-flat-same-name-foreign/b.sx";
#import "0729-modules-flat-same-name-extern/a.sx";
#import "0729-modules-flat-same-name-extern/b.sx";
main :: () -> i32 {
print("absval = {}\n", absval(-7));

View File

@@ -0,0 +1,5 @@
// One of two flat authors of `absval`, a `extern` libc binding. A consumer
// flat-importing BOTH must NOT see this as an ambiguous bare-call collision —
// extern authors are never rerouted by the bare-call resolver, so the call
// falls to the existing first-wins extern dispatch.
absval :: (n: i32) -> i32 extern libc "abs";

View File

@@ -0,0 +1,2 @@
// The second flat author of `absval` — the identical `extern` libc binding.
absval :: (n: i32) -> i32 extern libc "abs";

View File

@@ -1,5 +0,0 @@
// One of two flat authors of `absval`, a `#foreign` libc binding. A consumer
// flat-importing BOTH must NOT see this as an ambiguous bare-call collision —
// foreign authors are never rerouted by the bare-call resolver, so the call
// falls to the existing first-wins foreign dispatch.
absval :: (n: i32) -> i32 #foreign libc "abs";

View File

@@ -1,2 +0,0 @@
// The second flat author of `absval` — the identical `#foreign` libc binding.
absval :: (n: i32) -> i32 #foreign libc "abs";

View File

@@ -0,0 +1,29 @@
// Own-wins for ENUM-PAYLOAD type registration over a NAMESPACED import.
// Regression (issue 0132, broader class).
//
// `#import "modules/std.sx"` carries the stdlib `event.Event` struct — it is
// NAMESPACED (reachable only as `event.Event`), never flat-visible. This file
// ALSO authors its OWN `Event :: struct { code }`, used as the payload of the
// enum `Wrap`. The payload type name `Event` must resolve at REGISTRATION to
// THIS file's own `Event` (which has `code`), not the namespaced stdlib struct.
//
// Fail-before: `registerEnumDecl` built the tagged-union body through the
// stateless `type_bridge.buildEnumInfo`, whose flat `findByName` picked the
// wrong same-name author — `got`'s payload became the stdlib `Event`, so
// `e.code` errored "field 'code' not found on type 'Event'". Fixed by threading
// the visibility-aware resolver (`*Lowering` as the `resolveInner` hook) through
// `buildEnumInfo` / `buildUnionInfo`, matching what `registerStructDecl` already
// does for struct fields.
#import "modules/std.sx";
Event :: struct { code: i64; }
Wrap :: enum { none; got: Event; }
main :: () {
w : Wrap = .got(.{ code = 7 });
if w == {
case .got: (e) { print("code={}\n", e.code); }
case .none: print("none\n");
}
}

View File

@@ -0,0 +1,25 @@
// Own-wins for an INLINE struct field's member type over a NAMESPACED import.
// Regression (issue 0132's broader class — the inline-decl resolution boundary).
//
// `Holder.inner` is an inline `struct { e: Event }`. The member type `Event`
// must resolve to THIS file's `Event` (which has `code`), not the namespaced
// stdlib `event.Event` struct carried by `#import "modules/std.sx"` (reachable
// only as `event.Event`, never bare).
//
// Fail-before: `Lowering.resolveTypeWithBindings` delegated inline `struct_decl`
// field types to the FLAT `type_bridge.resolveAstType`, dropping the visibility
// context — so `e: Event` resolved via global `findByName` to the stdlib struct
// and `h.inner.e.code` errored "field 'code' not found on type 'Event'". Fixed
// by routing inline enum/struct/union decls through the `inner` recursion hook
// with `self` (visibility-aware), the same own-wins rule top-level decls use.
#import "modules/std.sx";
Event :: struct { code: i64; }
Holder :: struct { inner: struct { e: Event; }; }
main :: () {
h : Holder = ---;
h.inner.e = .{ code = 5 };
print("code={}\n", h.inner.e.code);
}

View File

@@ -9,7 +9,7 @@
#import "modules/std.sx";
// Internal runtime symbol (library/vendors/sx_trace_runtime/sx_trace.c).
sx_trace_len :: () -> u32 #foreign;
sx_trace_len :: () -> u32 extern;
E :: error { Bad }

View File

@@ -12,7 +12,7 @@
trace :: #import "modules/std/trace.sx";
// Buffer length probe (the runtime symbol; public read API is the trace module).
sx_trace_len :: () -> u32 #foreign;
sx_trace_len :: () -> u32 extern;
E :: error { BadInput, Overflow }

View File

@@ -1,7 +1,7 @@
// A reserved/builtin type name used as a PARAMETER name is rejected inside the
// two method-with-body forms that carry their params as bare name lists rather
// than `Param` nodes: a protocol default-body method (`u8`) and a sx-defined
// foreign-class (`#objc_class`) method (`i16`). The declaration-site diagnostic
// runtime-class (`#objc_class`) method (`i16`). The declaration-site diagnostic
// underlines the OFFENDING PARAMETER itself, not the enclosing `protocol` /
// `#objc_class` block — each method's `param_name_spans` is threaded from the
// parser so the caret lands on the parameter token.

View File

@@ -5,7 +5,7 @@
// reserved-name check, so a bare reserved-name function compiled silently and
// became callable — bypassing the backtick rule that handwritten sx must use.
// The backtick escape (`` `i2 :: … ``, examples/0153) is the only way to spell
// these names; `#import c` foreign decls remain exempt (examples/1220).
// these names; `#import c` extern decls remain exempt (examples/1220).
//
// Regression (issue 0089). Expected: one error per declaration, each caret on
// the declared name; exit 1.

View File

@@ -4,7 +4,7 @@
// examples/1140). Each is a declaration-name binding site: a bare reserved
// spelling there mis-classifies and is rejected, exactly like `i2 := …`. The
// backtick escape (`` `i2 :: struct{…} ``, examples/0154) is the only way to
// spell these names in handwritten sx; `#import c` foreign decls stay exempt
// spell these names in handwritten sx; `#import c` extern decls stay exempt
// (examples/1220).
//
// Regression (issue 0089 — attempt-4: 0076 holds across every decl kind).

View File

@@ -6,7 +6,7 @@
libc :: #library "c";
// std/process.sx already binds getenv as `-> *u8`; this view disagrees.
getenv_opt :: (name: [:0]u8) -> ?[:0]u8 #foreign libc "getenv";
getenv_opt :: (name: [:0]u8) -> ?[:0]u8 extern libc "getenv";
main :: () -> i32 {
p := getenv_opt("PATH");

View File

@@ -0,0 +1,11 @@
// Phase 4 (FFI-linkage) interplay diagnostic: `extern` and `export` are the two
// values of the same linkage axis — a declaration is either an import (`extern`)
// or a definition (`export`), never both. The parser rejects the redundant
// second keyword with a clear message (instead of the bare "expected ';'" the
// body parser would otherwise emit).
//
// Expected: one error caret on the second keyword; exit 1.
f :: (a: i32) -> i32 extern export;
main :: () -> i32 { 0 }

View File

@@ -0,0 +1,13 @@
// A parse error in an IMPORTED file must be located in THAT file, not the
// importer. Regression: `import_sources` was wired to the diagnostics only
// AFTER import resolution finished, so a parse error raised MID-resolution
// (which aborts before that wiring) could not resolve the imported file's
// source — the caret fell back to the root file and landed on an unrelated
// line. The fix wires `import_sources` before resolving and pins the
// diagnostic's `source_file` + offset to the imported file.
//
// The companion's error sits several lines down (after comments) so a caret
// mislocated against THIS importer would be unmistakable.
#import "1176-diagnostics-import-parse-error-location/broken.sx";
main :: () {}

View File

@@ -0,0 +1,6 @@
// Deliberately broken: exercises import parse-error LOCATION reporting.
// These leading comment lines push the parse error down so its line number
// differs from the importer's — a mislocated caret would point here-or-wrong
// instead of at the real offending token below.
//
broken :: 1 2;

View File

@@ -0,0 +1,18 @@
// Taking the address of a scalar `::` constant is a compile error: a scalar
// constant folds to its value and has NO storage (only array/struct constants
// are immutable globals with a real address — see 0177). Covers a module-scope
// const, a local const, and an inline-asm `-> @const` write-through (the path
// that surfaced the bug). Before the fix, `@N` lowered to `inttoptr (i64 40 to
// ptr)` — a wild pointer that segfaulted on deref and emitted invalid stores
// for asm `-> @const`. Regression (issue 0138).
takes :: (p: *i64) {}
N :: 40;
main :: () {
takes(@N); // module scalar const — no storage
x :: 7;
takes(@x); // local scalar const — no storage
asm volatile { "mov %[c], #99", [c] "=r" -> @N }; // write-through to a const
}

View File

@@ -1,5 +1,5 @@
// `callconv(.c)` on function pointers passed to foreign callbacks — ensures
// the function uses C ABI so it can be safely invoked from `#foreign`
// `callconv(.c)` on function pointers passed to extern callbacks — ensures
// the function uses C ABI so it can be safely invoked from `extern`
// functions like SDL_AddEventWatch.
#import "modules/std.sx";

View File

@@ -1,12 +1,12 @@
// Companion module for examples/94-foreign-global.sx (PLAN-FFI 0.10).
// Declares the same `#foreign` extern global as the main file; the
// Companion module for examples/1205-ffi-extern-global.sx (PLAN-FFI 0.10).
// Declares the same `extern` extern global as the main file; the
// linker should treat both decls as one symbol. We deliberately don't
// READ `@__stdinp` from inside a helper fn body — that path is busted
// today (see examples/issue-0037.sx) — we just expose a trivial fn so
// this file participates in the link and the cross-file decl
// coexistence is exercised.
__stdinp : *void #foreign;
__stdinp : *void extern;
stdinp_addr_present :: () -> i32 {
1

View File

@@ -1,29 +1,29 @@
// Extern data globals via `<name> : <type> #foreign;`. Lets sx code
// Extern data globals via `<name> : <type> extern;`. Lets sx code
// reference libSystem / framework symbols (NSConcreteStackBlock,
// __stdinp, etc.) for FFI bridges. Mirrors the long-standing
// `<fn> :: (...) -> ... #foreign;` form on the function side.
// `<fn> :: (...) -> ... extern;` form on the function side.
//
// Cross-file dimension (PLAN-FFI step 0.10): the helper companion
// `94-foreign-global-helper.sx` ALSO declares `__stdinp : *void #foreign;`.
// `1205-ffi-extern-global-helper.sx` ALSO declares `__stdinp : *void extern;`.
// Both files referencing the same extern symbol must link cleanly —
// LLVM dedupes the named global, the C linker resolves both refs to
// the one libSystem symbol.
//
// We *don't* check that the helper computes the same address — see
// issue-0037 (helper-function-scoped `@foreign_global` lowers to
// issue-0037 (helper-function-scoped `@extern_global` lowers to
// undef today). When that fixes, fold the helper's address back into
// the equality check here.
#import "modules/std.sx";
#import "1205-ffi-foreign-global-helper.sx";
#import "1205-ffi-extern-global-helper.sx";
__stdinp : *void #foreign;
__stdinp : *void extern;
main :: () -> i32 {
addr_bits : u64 = xx @__stdinp;
print("stdin extern global non-null: {}\n", addr_bits != 0);
// Force the helper symbol to participate in linking (otherwise the
// imported file's #foreign decl might get dropped by the
// imported file's #extern decl might get dropped by the
// dead-code stripper). The actual return value is busted today
// — see issue-0037.
_ := stdinp_addr_present();

View File

@@ -1,4 +1,4 @@
// 16-byte integer-only struct passed by value through `#foreign`.
// 16-byte integer-only struct passed by value through `extern`.
//
// emit_llvm.zig's `abiCoerceParamType` routes 9..16-byte non-HFA
// structs through `[2 x i64]` for register-pair passing on AAPCS64 /
@@ -22,7 +22,7 @@
Pair64 :: struct { a: i64; b: i64; }
ffi_pair64_swap :: (p: Pair64) -> Pair64 #foreign;
ffi_pair64_swap :: (p: Pair64) -> Pair64 extern;
main :: () -> i32 {
p : Pair64 = .{ a = 1, b = 2 };

View File

@@ -1,4 +1,4 @@
// `xx @<foreign_global>` round-trips through a non-main helper
// `xx @<extern_global>` round-trips through a non-main helper
// function: the helper's `xx @__stdinp` cast lowers to a `bitcast`
// IR opcode that emit_llvm.zig dispatches to `LLVMBuildPtrToInt`
// (BitCast doesn't accept ptr↔int on modern LLVM with opaque
@@ -12,7 +12,7 @@
#import "modules/std.sx";
__stdinp : *void #foreign;
__stdinp : *void extern;
stdinp_addr_via_helper :: () -> u64 {
xx @__stdinp

View File

@@ -1,5 +1,5 @@
// Phase 0 baseline (PLAN-FFI.md step 0.1): every primitive type passed
// in/out of a C `#foreign` fn via `#import c { #include / #source }`.
// in/out of a C `extern` fn via `#import c { #include / #source }`.
// Locks today's parameter + return ABI so Phase 1's lowering changes
// (`#objc_call` / `#jni_call`) can't silently regress us.
//

View File

@@ -1,5 +1,5 @@
// Phase 0 baseline (PLAN-FFI.md step 0.2): small structs (≤16 bytes)
// passed by value into a C `#foreign` fn and returned by value. Four
// passed by value into a C `extern` fn and returned by value. Four
// shapes that exercise distinct aggregate ABI paths:
// Vec2 — 8 B, two f32 (register pair, float)
// Vec4f — 16 B, four f32 (HFA — homogeneous float aggregate)
@@ -15,7 +15,7 @@
// `#source` only — c_import would rewrite struct-typed params/returns
// in the .h to *void (its struct/opaque pointer default), losing the
// by-value ABI. The hand-written #foreign decls below keep sx's
// by-value ABI. The hand-written extern decls below keep sx's
// struct types end-to-end.
#import c {
#source "1210-ffi-02-small-struct.c";
@@ -26,21 +26,21 @@ Vec4f :: struct { x: f32; y: f32; z: f32; w: f32; }
Pair64 :: struct { a: i64; b: i64; }
Quad32 :: struct { a: i32; b: i32; c: i32; d: i32; }
ffi_vec2_make :: (x: f32, y: f32) -> Vec2 #foreign;
ffi_vec2_swap :: (v: Vec2) -> Vec2 #foreign;
ffi_vec2_sum :: (v: Vec2) -> f32 #foreign;
ffi_vec2_make :: (x: f32, y: f32) -> Vec2 extern;
ffi_vec2_swap :: (v: Vec2) -> Vec2 extern;
ffi_vec2_sum :: (v: Vec2) -> f32 extern;
ffi_vec4f_make :: (x: f32, y: f32, z: f32, w: f32) -> Vec4f #foreign;
ffi_vec4f_reverse :: (v: Vec4f) -> Vec4f #foreign;
ffi_vec4f_sum :: (v: Vec4f) -> f32 #foreign;
ffi_vec4f_make :: (x: f32, y: f32, z: f32, w: f32) -> Vec4f extern;
ffi_vec4f_reverse :: (v: Vec4f) -> Vec4f extern;
ffi_vec4f_sum :: (v: Vec4f) -> f32 extern;
ffi_pair64_make :: (a: i64, b: i64) -> Pair64 #foreign;
ffi_pair64_swap :: (p: Pair64) -> Pair64 #foreign;
ffi_pair64_sum :: (p: Pair64) -> i64 #foreign;
ffi_pair64_make :: (a: i64, b: i64) -> Pair64 extern;
ffi_pair64_swap :: (p: Pair64) -> Pair64 extern;
ffi_pair64_sum :: (p: Pair64) -> i64 extern;
ffi_quad32_make :: (a: i32, b: i32, c: i32, d: i32) -> Quad32 #foreign;
ffi_quad32_reverse :: (q: Quad32) -> Quad32 #foreign;
ffi_quad32_sum :: (q: Quad32) -> i32 #foreign;
ffi_quad32_make :: (a: i32, b: i32, c: i32, d: i32) -> Quad32 extern;
ffi_quad32_reverse :: (q: Quad32) -> Quad32 extern;
ffi_quad32_sum :: (q: Quad32) -> i32 extern;
main :: () -> i32 {
// ── Vec2 (8 bytes, float pair) ─────────────────────────────────

View File

@@ -1,5 +1,5 @@
// Phase 0 baseline (PLAN-FFI.md step 0.3): structs >16 bytes passed
// by value into a C `#foreign` fn and returned by value. Exercises
// by value into a C `extern` fn and returned by value. Exercises
// the byval-pointer ABI path — the caller copies the struct onto its
// stack and hands a pointer to the callee; on AAPCS64 the return
// uses the indirect `x8` register; on SysV AMD64 the return is a
@@ -25,14 +25,14 @@ Big48 :: struct {
d: i64; e: i64; f: i64;
}
ffi_big24_make :: (a: i64, b: i64, c: i64) -> Big24 #foreign;
ffi_big24_rotate :: (v: Big24) -> Big24 #foreign;
ffi_big24_sum :: (v: Big24) -> i64 #foreign;
ffi_big24_make :: (a: i64, b: i64, c: i64) -> Big24 extern;
ffi_big24_rotate :: (v: Big24) -> Big24 extern;
ffi_big24_sum :: (v: Big24) -> i64 extern;
ffi_big48_make :: (a: i64, b: i64, c: i64,
d: i64, e: i64, f: i64) -> Big48 #foreign;
ffi_big48_reverse :: (v: Big48) -> Big48 #foreign;
ffi_big48_sum :: (v: Big48) -> i64 #foreign;
d: i64, e: i64, f: i64) -> Big48 extern;
ffi_big48_reverse :: (v: Big48) -> Big48 extern;
ffi_big48_sum :: (v: Big48) -> i64 extern;
main :: () -> i32 {
// ── Big24 (24 bytes, byval pointer) ────────────────────────────

View File

@@ -22,13 +22,13 @@
FQuad :: struct { a: f32; b: f32; c: f32; d: f32; }
DQuad :: struct { a: f64; b: f64; c: f64; d: f64; }
ffi_fquad_make :: (a: f32, b: f32, c: f32, d: f32) -> FQuad #foreign;
ffi_fquad_reverse :: (v: FQuad) -> FQuad #foreign;
ffi_fquad_sum :: (v: FQuad) -> f32 #foreign;
ffi_fquad_make :: (a: f32, b: f32, c: f32, d: f32) -> FQuad extern;
ffi_fquad_reverse :: (v: FQuad) -> FQuad extern;
ffi_fquad_sum :: (v: FQuad) -> f32 extern;
ffi_dquad_make :: (a: f64, b: f64, c: f64, d: f64) -> DQuad #foreign;
ffi_dquad_reverse :: (v: DQuad) -> DQuad #foreign;
ffi_dquad_sum :: (v: DQuad) -> f64 #foreign;
ffi_dquad_make :: (a: f64, b: f64, c: f64, d: f64) -> DQuad extern;
ffi_dquad_reverse :: (v: DQuad) -> DQuad extern;
ffi_dquad_sum :: (v: DQuad) -> f64 extern;
main :: () -> i32 {
// ── FQuad (16 B, 4×f32 HFA) ────────────────────────────────────

View File

@@ -14,11 +14,11 @@
#source "1213-ffi-05-string-args.c";
};
ffi_strlen :: (s: [:0]u8) -> i32 #foreign;
ffi_first_byte :: (s: [:0]u8) -> i32 #foreign;
ffi_sum_bytes :: (buf: [*]u8, len: i32) -> i32 #foreign;
ffi_write_byte :: (buf: [*]u8, idx: i32, v: u8) -> void #foreign;
ffi_static_greeting :: () -> [*]u8 #foreign;
ffi_strlen :: (s: [:0]u8) -> i32 extern;
ffi_first_byte :: (s: [:0]u8) -> i32 extern;
ffi_sum_bytes :: (buf: [*]u8, len: i32) -> i32 extern;
ffi_write_byte :: (buf: [*]u8, idx: i32, v: u8) -> void extern;
ffi_static_greeting :: () -> [*]u8 extern;
main :: () -> i32 {
// ── [:0]u8 null-terminated literal ─────────────────────────────

View File

@@ -16,8 +16,8 @@
#source "1214-ffi-06-callback.c";
};
ffi_apply_callback :: (cb: (i32) -> i32 callconv(.c), value: i32) -> i32 #foreign;
ffi_apply_callback2 :: (cb: (*void, i32) -> i32 callconv(.c), ctx: *void, v: i32) -> i32 #foreign;
ffi_apply_callback :: (cb: (i32) -> i32 callconv(.c), value: i32) -> i32 extern;
ffi_apply_callback2 :: (cb: (*void, i32) -> i32 callconv(.c), ctx: *void, v: i32) -> i32 extern;
g_callback_hits : i32 = 0;
g_callback_sum : i32 = 0;

View File

@@ -6,7 +6,7 @@
// search branch (`<exe>/../../library` etc.), not by the CWD or
// importing-file's-dir branches.
//
// `#include` triggers c_import.zig's auto-synthesis of `#foreign`
// `#include` triggers c_import.zig's auto-synthesis of `extern`
// fn decls from the C header; `#source` adds the .c to the build's
// object list. Together they let the sx side call the C functions
// by their declared names with no manual decls.

View File

@@ -1,3 +1,3 @@
#include "1216-ffi-08-foreign-in-method.h"
#include "1216-ffi-08-extern-in-method.h"
int ffi_method_helper(int x) { return x * 10; }

View File

@@ -1,4 +1,4 @@
// Phase 0 baseline (PLAN-FFI.md step 0.8): `#foreign` C call sites
// Phase 0 baseline (PLAN-FFI.md step 0.8): `extern` C call sites
// embedded inside the major sx surface constructs. None of these
// touch a new ABI shape — they only verify lowering routes the call
// through identically regardless of the enclosing context:
@@ -12,11 +12,11 @@
#import "modules/build.sx";
#import c {
#include "1216-ffi-08-foreign-in-method.h";
#source "1216-ffi-08-foreign-in-method.c";
#include "1216-ffi-08-extern-in-method.h";
#source "1216-ffi-08-extern-in-method.c";
};
// ── 1. Struct method calling a #foreign fn ───────────────────────────
// ── 1. Struct method calling a #extern fn ───────────────────────────
Counter :: struct {
seed: i32 = 0;
next :: (self: *Counter) -> i32 {
@@ -26,7 +26,7 @@ Counter :: struct {
}
}
// ── 2. Protocol impl method calling a #foreign fn ────────────────────
// ── 2. Protocol impl method calling a #extern fn ────────────────────
Doubler :: protocol {
doubled :: (self: *Self) -> i32;
}
@@ -37,7 +37,7 @@ impl Doubler for Counter {
}
}
// ── 3. Closure body calling a #foreign fn ────────────────────────────
// ── 3. Closure body calling a #extern fn ────────────────────────────
make_adder :: (bias: i32) -> Closure(i32) -> i32 {
closure((x: i32) -> i32 => ffi_method_helper(x) + bias)
}

View File

@@ -1,4 +1,4 @@
#include "1217-ffi-09-foreign-result-chain.h"
#include "1217-ffi-09-extern-result-chain.h"
#include <stdlib.h>
void *ffi_chain_make(int seed) {

View File

@@ -12,8 +12,8 @@
#import "modules/std.sx";
#import c {
#include "1217-ffi-09-foreign-result-chain.h";
#source "1217-ffi-09-foreign-result-chain.c";
#include "1217-ffi-09-extern-result-chain.h";
#source "1217-ffi-09-extern-result-chain.c";
};
// Struct field hosts an FFI-returned handle.

View File

@@ -1,28 +0,0 @@
// `#foreign` C-variadic tail: trailing `..args: []T` on a foreign fn maps
// to the C calling convention's `...`. Extras at the call site are
// passed via the variadic slot with the standard default argument
// promotion (i8/i16/bool → i32, f32 → f64) applied implicitly.
#import "modules/std.sx";
#import c {
#source "1218-ffi-foreign-cvariadic.c";
};
sx_ffi_sum_ints :: (n: i32, ..args: []i32) -> i64 #foreign;
sx_ffi_avg_doubles :: (n: i32, ..args: []f64) -> f64 #foreign;
sx_ffi_count_args :: (tag: *u8, ..args: []*u8) -> i32 #foreign;
main :: () -> i32 {
print("sum_ints(3, 10, 20, 30) = {}\n", sx_ffi_sum_ints(3, 10, 20, 30));
print("sum_ints(0) = {}\n", sx_ffi_sum_ints(0));
print("avg_doubles(2) = {}\n", sx_ffi_avg_doubles(2, 1.5, 2.5));
print("avg_doubles(3) = {}\n", sx_ffi_avg_doubles(3, 1.0, 2.0, 3.0));
a := "alpha".ptr;
b := "beta".ptr;
g := "gamma".ptr;
sentinel : *u8 = null;
print("count_args(3 strs) = {}\n", sx_ffi_count_args("tag".ptr, a, b, g, sentinel));
0
}

View File

@@ -4,20 +4,20 @@
#import "modules/std/test.sx";
pkg :: #import "tests/fixtures/testpkg";
// --- Foreign function binding ---
// --- Extern function binding ---
libc :: #library "c";
c_abs :: (n: i32) -> i32 #foreign libc "abs";
c_abs :: (n: i32) -> i32 extern libc "abs";
// --- Protocol declarations (Phase 1: static dispatch only) ---
main :: () {
// ========================================================
// 15. FOREIGN FUNCTION BINDING
// 15. EXTERN FUNCTION BINDING
// ========================================================
print("=== 15. Foreign ===\n");
print("=== 15. Extern ===\n");
// Symbol rename: c_abs maps to C's abs()
print("foreign-rename: {}\n", c_abs(xx -42));
print("extern-rename: {}\n", c_abs(xx -42));
}

View File

@@ -1,6 +1,6 @@
/* Foreign C declarations whose names collide with sx's reserved type spellings.
/* Extern C declarations whose names collide with sx's reserved type spellings.
The `#import c` exemption must accept these generated names unedited, both as
parameter names (`i1`, `i2`) and as a FUNCTION name (`i2`) — and a foreign
parameter names (`i1`, `i2`) and as a FUNCTION name (`i2`) — and an extern
reserved-name function must be bare-callable (issue 0089). */
int ffi_pick(int i1, int i2, int which);
int ffi_sum(int i1, int i2);

View File

@@ -1,10 +1,10 @@
// `#import c` foreign-name exemption: C names that collide with sx's reserved
// type spellings import unedited. Foreign decls are treated as RAW — their names
// are never type-classified nor reserved-checked — so the generated `#foreign`
// `#import c` extern-name exemption: C names that collide with sx's reserved
// type spellings import unedited. Extern decls are treated as RAW — their names
// are never type-classified nor reserved-checked — so the generated `extern`
// bindings import and call without hand-edits (no backticks needed). This covers
// parameter names (`i1`/`i2`), a function whose own NAME is a reserved spelling
// (`i2`), and bare-calling that function (its callee spelling parses as a type
// but resolves to the foreign fn). Before issue 0089 the params errored with
// but resolves to the extern fn). Before issue 0089 the params errored with
// "'i1' is a reserved type name and cannot be used as an identifier", and the
// bare call errored with "unresolved 'i2'".
// Regression (issue 0089).

View File

@@ -1,4 +1,4 @@
// Foreign `-> [:0]u8` / `-> ?[:0]u8` returns: C hands back ONE `char *`;
// Extern `-> [:0]u8` / `-> ?[:0]u8` returns: C hands back ONE `char *`;
// the fat sx string is synthesized at the call boundary ({ptr, strlen};
// NULL maps to the optional's null / an empty string) — issue 0128.
// Pre-fix, the call read the pointer register pair as {ptr, len} and the
@@ -6,9 +6,9 @@
#import "modules/std.sx";
libc :: #library "c";
err_text :: (code: i32) -> [:0]u8 #foreign libc "strerror";
sig_text :: (sig: i32) -> ?[:0]u8 #foreign libc "strsignal";
dlerror :: () -> ?[:0]u8 #foreign libc;
err_text :: (code: i32) -> [:0]u8 extern libc "strerror";
sig_text :: (sig: i32) -> ?[:0]u8 extern libc "strsignal";
dlerror :: () -> ?[:0]u8 extern libc;
main :: () -> i32 {
// plain: strerror(0) = "Undefined error: 0" on macOS — assert shape,

View File

@@ -1,14 +1,14 @@
// The `cstring` type: ONE pointer to a null-terminated u8 buffer — C's
// `char *`. Crosses #foreign boundaries verbatim in both directions;
// `char *`. Crosses extern boundaries verbatim in both directions;
// `?cstring` is the nullable case (null pointer = absent); string
// LITERALS coerce implicitly (terminated constants); arbitrary strings
// materialize via to_cstring; from_cstring is the zero-copy view back.
#import "modules/std.sx";
libc :: #library "c";
strerror_c :: (code: i32) -> cstring #foreign libc "strerror";
getenv_c :: (name: cstring) -> ?cstring #foreign libc "getenv";
dlerror_c :: () -> ?cstring #foreign libc "dlerror";
strerror_c :: (code: i32) -> cstring extern libc "strerror";
getenv_c :: (name: cstring) -> ?cstring extern libc "getenv";
dlerror_c :: () -> ?cstring extern libc "dlerror";
main :: () -> i32 {
// literal -> cstring param; cstring return -> view

View File

@@ -0,0 +1,14 @@
// extern function binding (FFI-linkage stream, Phase 1): bind libc's `abs`
// directly via the bare `extern` linkage modifier — no `extern`, no
// `#library`. `extern` ⇒ external linkage + C ABI + no sx ctx; the symbol
// resolves against the default-linked libc at link time. The sx name `abs`
// IS the C symbol (no rename — the `extern LIB "csym"` forms land in 1.2).
#import "modules/std.sx";
abs :: (n: i32) -> i32 extern;
main :: () -> i32 {
print("abs(-7) = {}\n", abs(xx -7));
print("abs(42) = {}\n", abs(xx 42));
0
}

View File

@@ -0,0 +1,13 @@
// extern with a "csym" rename (FFI-linkage stream, Phase 1.2): the sx name
// `c_abs` binds C's `abs` via the optional symbol-name override after the
// `extern` keyword — mirrors `extern "abs"`. The optional `LIB` ident slot
// (extern_lib) sits before the string; here it's omitted (libc is
// default-linked).
#import "modules/std.sx";
c_abs :: (n: i32) -> i32 extern "abs";
main :: () -> i32 {
print("c_abs(-42) = {}\n", c_abs(xx -42));
0
}

View File

@@ -0,0 +1,15 @@
// extern data global (FFI-linkage stream, Phase 1.2): reference a symbol
// defined elsewhere (here libSystem's __stdinp) via the bare `extern`
// linkage modifier on a typed var decl — the extern-named counterpart of
// `<name> : <type> extern;` (see examples/1205). The optional
// `extern [LIB] ["csym"]` tail mirrors the fn form; bare here (the sx name
// IS the C symbol, resolved against the default-linked libSystem).
#import "modules/std.sx";
__stdinp : *void extern;
main :: () -> i32 {
addr_bits : u64 = xx @__stdinp;
print("stdin extern global non-null: {}\n", addr_bits != 0);
0
}

View File

@@ -0,0 +1,8 @@
#include "1226-ffi-export-fn.h"
// Defined on the sx side via `export` — a plain C-ABI symbol, no sx context.
extern int sx_square(int n);
int call_sx_square(int n) {
return sx_square(n) + 1;
}

View File

@@ -0,0 +1,7 @@
#ifndef SX_EXPORT_FN_H
#define SX_EXPORT_FN_H
// Calls back into the sx-exported `sx_square` and adds 1.
int call_sx_square(int n);
#endif

View File

@@ -0,0 +1,28 @@
// export function (FFI-linkage stream, Phase 2): define an sx function with
// the bare `export` linkage modifier — external linkage + C ABI + no sx ctx —
// so a companion C translation unit can call back into it by its plain symbol
// name. The C side (`#source`) declares `sx_square` as a normal `extern int`
// and calls it; sx `main` drives the C side via `call_sx_square`. Mirrors the
// import-direction `extern` examples (12231225) for the define direction.
//
// Without `export`, an sx-defined fn is `internal` linkage + carries the
// implicit `__sx_ctx` slot, so the C object can neither resolve nor correctly
// call the symbol — this is the gap `export` fills.
#import "modules/std.sx";
#import c {
#include "1226-ffi-export-fn.h";
#source "1226-ffi-export-fn.c";
};
// sx-defined, exported to C: external linkage + C ABI + no implicit ctx.
sx_square :: (n: i32) -> i32 export {
return n * n;
}
main :: () -> i32 {
// call_sx_square (C) calls back into sx_square, adds 1.
print("call_sx_square(6) = {}\n", call_sx_square(6));
print("call_sx_square(9) = {}\n", call_sx_square(9));
0
}

View File

@@ -0,0 +1,8 @@
#include "1227-ffi-export-fn-rename.h"
// Defined on the sx side via `export "triple_c"` — a plain C-ABI symbol.
extern int triple_c(int n);
int call_triple(int n) {
return triple_c(n) + 1;
}

View File

@@ -0,0 +1,7 @@
#ifndef SX_EXPORT_FN_RENAME_H
#define SX_EXPORT_FN_RENAME_H
// Calls back into the sx-exported `triple_c` and adds 1.
int call_triple(int n);
#endif

View File

@@ -0,0 +1,23 @@
// export with a "csym" rename (FFI-linkage stream, Phase 2.2): the sx name
// `sx_triple` is exposed to C under the symbol `triple_c` via the optional
// symbol-name override after `export` — the define-direction mirror of
// `extern "csym"` (1224). The companion C calls `triple_c` by that name; sx
// `main` drives it via `call_triple`. Runs in AOT mode (see the `.aot`
// marker) because a C->sx-by-name call cannot link against a JIT-resident
// symbol.
#import "modules/std.sx";
#import c {
#include "1227-ffi-export-fn-rename.h";
#source "1227-ffi-export-fn-rename.c";
};
// sx-defined, exported to C under the C symbol `triple_c`.
sx_triple :: (n: i32) -> i32 export "triple_c" {
return n * 3;
}
main :: () -> i32 {
print("call_triple(7) = {}\n", call_triple(7));
0
}

View File

@@ -0,0 +1,14 @@
// `#import` is non-transitive for C-import functions: main imports b,
// b imports c, so main must NOT see c's lib-less `extern` C functions
// directly. Referencing either is rejected by the C-import visibility
// gate (lower/decl.zig `c_import_bare`) with a C-specific "not visible"
// diagnostic — not the generic top-level-name wording. Two distinct
// extern symbols pin that the gate fires per-symbol.
#import "modules/std.sx";
#import "1228-ffi-extern-c-non-transitive/b.sx";
main :: () -> i32 {
print("{}\n", c_abs_one(-3));
print("{}\n", c_abs_two(-4));
return 0;
}

View File

@@ -0,0 +1,7 @@
// Intermediate module: directly imports c.sx, so BOTH of c's lib-less
// C functions are legitimately visible here (the legal usage site).
#import "c.sx";
b_use :: () -> i32 {
return c_abs_one(-1) + c_abs_two(-2);
}

View File

@@ -0,0 +1,5 @@
// Two lib-less `extern` C-symbol imports: each declares a C function
// resolved at link time with no library reference. Both are policed by
// the non-transitive C-import visibility gate, per-symbol.
c_abs_one :: (x: i32) -> i32 extern;
c_abs_two :: (x: i32) -> i32 extern;

View File

@@ -1,6 +1,6 @@
#include <stdarg.h>
long long sx_ffi_sum_ints(int n, ...) {
long long sx_ext_sum_ints(int n, ...) {
va_list ap;
va_start(ap, n);
long long total = 0;
@@ -9,7 +9,7 @@ long long sx_ffi_sum_ints(int n, ...) {
return total;
}
double sx_ffi_avg_doubles(int n, ...) {
double sx_ext_avg_doubles(int n, ...) {
va_list ap;
va_start(ap, n);
double total = 0.0;
@@ -18,13 +18,3 @@ double sx_ffi_avg_doubles(int n, ...) {
if (n == 0) return 0.0;
return total / n;
}
int sx_ffi_count_args(const char *tag, ...) {
(void) tag;
va_list ap;
va_start(ap, tag);
int count = 0;
while (va_arg(ap, const char *) != 0) count++;
va_end(ap);
return count;
}

View File

@@ -0,0 +1,26 @@
// `extern` C-variadic tail: a trailing `..args: []T` on an `extern` fn
// maps to the C calling convention's `...`. Extras at the call site pass through the variadic
// slot with standard default argument promotion (i8/i16/bool → i32,
// f32 → f64), NOT packed into an sx slice.
//
// Regression (FFI-linkage Part B): the `is_variadic` drop in
// `declareFunction` + the call-site early-out in `packVariadicCallArgs`
// were gated on `extern` only, so a migrated variadic `extern` lost
// its `...` tail and slice-packed the extras (garbage at the C ABI).
#import "modules/std.sx";
#import c {
#source "1229-ffi-extern-cvariadic.c";
};
sx_ext_sum_ints :: (n: i32, ..args: []i32) -> i64 extern;
sx_ext_avg_doubles :: (n: i32, ..args: []f64) -> f64 extern;
main :: () -> i32 {
print("sum_ints(3, 10, 20, 30) = {}\n", sx_ext_sum_ints(3, 10, 20, 30));
print("sum_ints(0) = {}\n", sx_ext_sum_ints(0));
print("avg_doubles(2) = {}\n", sx_ext_avg_doubles(2, 1.5, 2.5));
print("avg_doubles(3) = {}\n", sx_ext_avg_doubles(3, 1.0, 2.0, 3.0));
0
}

View File

@@ -0,0 +1,21 @@
// Two flat FILE imports each declare the SAME libc symbol `absval` via the
// `extern` keyword (the linkage-keyword twin of example 0729's `extern`
// form). The bare-call resolver must NOT count extern authors when deciding
// ambiguity — they are external C symbols, never rerouted by the bare-call
// machinery, so the existing first-wins extern dispatch binds the
// call and a same-name extern collision compiles + runs (prints 7), it does
// NOT error as ambiguous.
//
// Regression (FFI-linkage Part B): `isPlainFreeFn` / `isPlainFreeFnDecl`
// excluded a `extern` body but classified an empty-block `extern` fn as a
// plain free function, so the two extern authors were wrongly counted as an
// ambiguous bare-call collision. Prerequisite for migrating the fn-decl
// `extern` path onto `extern`.
#import "modules/std.sx";
#import "1230-ffi-extern-same-name-authors/a.sx";
#import "1230-ffi-extern-same-name-authors/b.sx";
main :: () -> i32 {
print("absval = {}\n", absval(-7));
0
}

View File

@@ -0,0 +1,6 @@
// One of two flat authors of `absval`, an `extern` libc binding — the
// `extern` twin of example 0729's `extern libc "abs"`. A consumer
// flat-importing BOTH must NOT see this as an ambiguous bare-call
// collision: extern authors (external C symbols) are excluded from the
// bare-call ambiguity verdict, exactly like their `extern` twins.
absval :: (n: i32) -> i32 extern libc "abs";

View File

@@ -0,0 +1,2 @@
// The second flat author of `absval` — the identical `extern` binding.
absval :: (n: i32) -> i32 extern libc "abs";

View File

@@ -0,0 +1,19 @@
// An `extern LIB "csym"` reference must name something real, exactly like
// its `extern LIB` twin (example 1620): `nosuchunit` names neither a
// #library constant nor a named `#import c` unit, so this is a compile-time
// diagnostic — the bogus library reference is caught BEFORE the symbol
// would silently resolve through whatever image happens to carry it.
//
// Regression (FFI-linkage Part B): `checkExternRefs` validated only a
// `extern` (extern-import shape) library_ref and skipped the `extern` keyword's
// `extern_lib`, so a bogus `extern` lib reference compiled silently (the
// symbol resolved via the default image and ran). Prerequisite for
// migrating the fn-decl `extern` path onto `extern`.
#import "modules/std.sx";
c_abs :: (n: i32) -> i32 extern nosuchunit "abs";
main :: () -> i32 {
print("c_abs = {}\n", c_abs(-5));
0
}

View File

@@ -1,23 +1,23 @@
// Chained foreign-class method dispatch: `Cls.static().instance(...)`
// Chained runtime-class method dispatch: `Cls.static().instance(...)`
// resolves the inner call's return type so the outer dispatch's
// receiver type is known. Pre-fix this collapsed to i64 in
// `inferExprType`, the foreign_class_map lookup missed, and lowering
// `inferExprType`, the runtime_class_map lookup missed, and lowering
// emitted `error: unresolved 'init'` (or 'initWithWindowScene' etc.)
// — see issues/0043 for the chess uikit.sx C4 migration that hit it.
//
// Two return-type shapes covered: explicit `*ClassName` (alloc here)
// and `*Self` (init). Both must propagate through the chain so the
// next `.method(...)` finds the foreign-class declaration.
// next `.method(...)` finds the runtime-class declaration.
#import "modules/std.sx";
#import "modules/build.sx";
NSObject :: #foreign #objc_class("NSObject") {
NSObject :: #objc_class("NSObject") extern {
alloc :: () -> *NSObject;
init :: (self: *Self) -> *Self;
}
NSObjectSelfReturn :: #foreign #objc_class("NSObject") {
NSObjectSelfReturn :: #objc_class("NSObject") extern {
alloc :: () -> *Self;
init :: (self: *Self) -> *Self;
}

View File

@@ -1,7 +1,7 @@
// M1.0 (xfail) — '=>' expression-body form inside '#objc_class'
// member methods.
//
// Today: parseForeignClassDecl ([src/parser.zig:1262]) accepts ';'
// Today: parseRuntimeClassDecl ([src/parser.zig:1262]) accepts ';'
// (declaration) or '{ ... }' (block body) but not '=>'. Trying
// '=>' surfaces 'expected ;' at the arrow.
//

View File

@@ -3,7 +3,7 @@
// `id`, `Class`, `SEL`, `BOOL` from `modules/ffi/objc.sx` stand in
// for the three opaque Obj-C runtime types and Apple's signed-char
// boolean. They resolve to `*void` / `i8` at the LLVM layer — no
// runtime cost — but make foreign-class and call-site declarations
// runtime cost — but make runtime-class and call-site declarations
// read closer to Objective-C source.
//
// `Class(T)` parameterization (phantom T, `#extends`-aware
@@ -15,8 +15,8 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
// Foreign-class declaration using the aliases at param/return positions.
NSObjectAlias :: #foreign #objc_class("NSObject") {
// Runtime-class declaration using the aliases at param/return positions.
NSObjectAlias :: #objc_class("NSObject") extern {
alloc :: () -> *Self;
init :: (self: *Self) -> *Self;
isKindOfClass :: (self: *Self, cls: Class) -> BOOL;

View File

@@ -20,7 +20,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
class_getInstanceVariable :: (cls: *void, name: [*]u8) -> *void #foreign objc;
class_getInstanceVariable :: (cls: *void, name: [*]u8) -> *void extern objc;
SxFoo :: #objc_class("SxFoo") {
counter: i32;

View File

@@ -15,7 +15,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
class_getMethodImplementation :: (cls: *void, sel: *void) -> *void #foreign objc;
class_getMethodImplementation :: (cls: *void, sel: *void) -> *void extern objc;
SxFoo :: #objc_class("SxFoo") {
counter: i32;

Some files were not shown because too many files have changed in this diff Show More