Files
sx/current/PLAN-EXTERN-EXPORT.md
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

12 KiB

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: docs/inline-asm-design.md §II.2 (Deviation 6) + §II.10 #4 + the syntax evaluation.

Decided syntax

name :: (sig) -> Ret  [callconv(.x)]  [extern | export]  [;|{…}];   // functions
Name :: #objc_class("X")  [extern | export]  { … };                  // aggregates (mirrors `struct #compiler`)
g    :  Type  extern ["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.
  • Library stays a separate axis (#library/build flags), not folded into extern.

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); add VarDecl.is_extern/extern_name. The IR is already extern-named (Function.is_extern, Builder.declareExtern).

Key finding (scopes Part A): the IR + LLVM emit already support everythingFunction.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: externis_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 #foreignextern/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 (sqlib98 libc61 objc22 tlib12 raylib7 clib/pcaplib3). 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 #foreignextern (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 linkageextern* foreign_expr(25) eliminated (folds into modifier) · is_foreign(39)→is_extern · foreign_lib/foreign_nameextern_* · foreign_name_mapextern_name_map · callForeign(8)→callExtern · marshalForeignArgmarshalExternArg · is_foreign_c_api(5)→is_extern_c_api · dedupeForeignSymboldedupeExternSymbol
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.md0

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. library separate. Part B (confirm before Phase 9): 5. runtime-class rename target — Runtime*Class* (recommended; it's the object-model axis, not linkage) vs Extern*Class*. 6. historical carve-out — keep issues/*.md (+ design-doc prose) as provenance, gate only the live tree (recommended) vs purge everything.

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 docs/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).