Compare commits

...

90 Commits

Author SHA1 Message Date
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
333 changed files with 8124 additions and 1677 deletions

3
.gitignore vendored
View File

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

View File

@@ -430,15 +430,11 @@ After any compiler change:
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").
3. **Standalone corpus run** (optional): `bash tests/run_examples.sh`
- Runs the corpus independent of `zig build test` (used by
`tools/verify-step.sh`). `--update` still regenerates snapshots and
produces byte-identical output to `-Dupdate-goldens`.
- Every test must show `ok` (currently 626); zero failures, zero timeouts.
- Uses GNU `timeout`/`gtimeout` when present (Homebrew coreutils on macOS)
and runs without a per-test wall-clock guard when neither is found.
- The two normalizers (`normalize`/`normalize_ir` in the script and the
mirrors in `src/corpus_run.test.zig`) must stay in lockstep.
`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). An
`expected/<name>.aot` marker 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).
### Test layout
@@ -495,7 +491,6 @@ There is no monolithic smoke file — each feature is its own focused example.
| `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. |
| `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`. |
| `tests/run_examples.sh` | Standalone shell runner (used by `tools/verify-step.sh`); same compare + `--update` as the Zig test. |
### Unit test file convention
@@ -558,7 +553,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 |
|------|------|
@@ -568,7 +563,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

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

@@ -0,0 +1,24 @@
# sx Inline Assembly — Checkpoint (ASM stream)
Companion to `current/PLAN-ASM.md`; design in
[docs/inline-asm-design.md](../docs/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
None — plan authored, not yet started.
## Current state
Design fully converged (`docs/inline-asm-design.md`). Feasibility confirmed:
`llvm_api.c.*` exposes `LLVMGetInlineAsm` / `LLVMBuildCall2` /
`LLVMAppendModuleInlineAsm` (LLVM@19). No code written.
## Next step
**A.0** — add the `kw_asm` keyword (`src/token.zig` Tag + `StaticStringMap`) and a
unit lex test. Then A.1 (parse `asm { … }``AsmExpr`, lowering bails loudly).
## Log
- (init) Plan + design doc written; ASM stream opened.
## Known issues
None yet.

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.**

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

@@ -0,0 +1,82 @@
# sx Inline Assembly — Implementation Plan (ASM stream)
**Design source of truth:** [docs/inline-asm-design.md](../docs/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-*` (must declare `target=`).
Never regenerate snapshots while red.
## 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.

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:** [docs/inline-asm-design.md](../docs/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
> `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).

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."

997
docs/inline-asm-design.md Normal file
View File

@@ -0,0 +1,997 @@
# Inline Assembly for sx — Design Doc & Proposal
**Status:** proposal / not yet scheduled into a workstream
**Author:** research pass over the Zig compiler (`~/projects/zig`, 0.16-dev) + the sx compiler
**Scope:** how Zig implements inline assembly end-to-end, and a minimal-deviation proposal to bring the same model to sx.
> Guiding constraint for this doc: **mirror Zig's design; deviate only where sx's
> grammar or stdlib makes a 1:1 copy impossible, and call every deviation out
> explicitly with its justification.** Every deviation below is tagged
> **[DEVIATION]** with a reason.
---
## 0. TL;DR + feasibility
* **Feasible today, no new infrastructure.** sx already links LLVM (`build.zig:10`
`/opt/homebrew/opt/llvm@19`) and `@cImport`s `llvm-c/Core.h`
(`src/llvm_api.zig:1-17`). That header exposes everything inline asm needs,
reachable right now through `llvm_api.c.*`:
* `LLVMGetInlineAsm(Ty, AsmString, AsmStringSize, Constraints, ConstraintsSize, HasSideEffects, IsAlignStack, Dialect, CanThrow)` — builds the asm callee (LLVM 19/21 share this 9-arg signature).
* `LLVMInlineAsmDialectATT` / `LLVMInlineAsmDialectIntel`.
* `LLVMBuildCall2(...)` — already used pervasively in `src/ir/emit_llvm.zig` (e.g. the Obj-C msgSend path) — calls the asm value like a function.
* `LLVMAppendModuleInlineAsm(M, Asm, Len)` — module-level (global) asm.
* **The hard part is not codegen.** Codegen is ~80 lines of well-trodden LLVM-C.
The real work is (a) the parser grammar, (b) a faithful port of Zig's
*LLVM constraint-string assembly* and *`%[name]`→`$N` template rewrite*, and
(c) Sema validation rules. All three are fully specified below.
* **Surface form (decided, §II.2):** `asm volatile { "tmpl", "=r" -> T, "r" = x, clobbers(.cc, .memory) }`
— a brace block; `->` marks outputs / `=` marks inputs (no positional `:`
sections); enum-literal `clobbers(.…)`; and N `-> Type` outputs return a
**tuple** (sx has tuples — Zig caps at one output).
* **Inline asm is never comptime-evaluable.** The interpreter must bail loudly
(`bailDetail`), per CLAUDE.md's "no silent unimplemented arms" rule.
* **One naming note:** sx already has a `sx asm <file>` *CLI subcommand*
(`src/main.zig:203,386`) that emits a `.s` file. That is a compiler output
mode, a different namespace from a language token. No conflict, but worth
knowing so nobody confuses the two.
---
# PART I — How Zig implements inline assembly
All file references in Part I are under `~/projects/zig` (0.16-dev,
commit `3deb86bafd`). Parser/AST/AstGen live in `lib/std/zig/`; Sema/AIR/codegen
in `src/`.
## I.1 Surface syntax
The canonical example (`doc/langref/inline_assembly.zig`), a Linux x86_64 syscall:
```zig
pub fn syscall3(number: usize, arg1: usize, arg2: usize, arg3: usize) usize {
return asm volatile ("syscall"
: [ret] "={rax}" (-> usize),
: [number] "{rax}" (number),
[arg1] "{rdi}" (arg1),
[arg2] "{rsi}" (arg2),
[arg3] "{rdx}" (arg3),
: .{ .rcx = true, .r11 = true });
}
```
Grammar shape:
```
asm volatile? ( <template-string>
: <output-item> , <output-item> , ... # outputs (optional section)
: <input-item> , <input-item> , ... # inputs (optional section)
: <clobbers> ) # clobbers (optional section)
output-item : [name] "constraint" (-> Type) # asm result becomes the value
| [name] "constraint" (lvalue) # asm writes through the pointer
input-item : [name] "constraint" (expr)
clobbers : .{ .reg0 = true, .reg1 = true } # struct literal (0.16-dev)
```
Key semantics (from `doc/langref.html.in:4217-4300`):
* **`volatile`** marks side effects. Without it, an asm expression whose result
is unused may be deleted. An asm expression with **no outputs must be
`volatile`** (else compile error).
* **x86/x86_64 use AT&T syntax** (LLVM provides the parser; Intel support is
"buggy and not well tested").
* **`%[name]`** in the template refers to a named operand's register; **`%%`** is
a literal `%`.
* **Clobbers** are registers the asm trashes that are *not* inputs/outputs.
`"memory"` (the `.memory = true` field) means "writes to arbitrary memory."
Failing to declare a clobber is unchecked illegal behavior.
* **Global assembly** = an `asm(...)` in a namespace-level `comptime` block. It
has *different rules*: `volatile` is forbidden, there are **no inputs/outputs/
clobbers**, no `%` substitution, and all global asm is concatenated verbatim:
```zig
// doc/langref/test_global_assembly.zig
comptime {
asm (
\\.global my_func;
\\.type my_func, @function;
\\my_func:
\\ lea (%rdi,%rsi,1),%eax
\\ retq
);
}
extern fn my_func(a: i32, b: i32) i32; // call into the global-asm symbol
```
## I.2 Pipeline, stage by stage
### Tokenizer — `lib/std/zig/tokenizer.zig`
Two keywords in the `StaticStringMap`: `.{ "asm", .keyword_asm }` and
`.{ "volatile", .keyword_volatile }`.
### AST — `lib/std/zig/Ast.zig`
Four node tags (`Ast.zig:3789-3817`):
* `asm_simple` — `asm(template)` only, no operands.
* `@"asm"` — full form; `data` is `node_and_extra` → (template node, `ExtraIndex` to an `Asm`).
* `asm_output` — `[a] "b" (-> Type)` or `[a] "b" (ident)`.
* `asm_input` — `[a] "b" (expr)`.
The "full" view the rest of the compiler consumes (`Ast.zig:2797-2809`):
```zig
pub const Asm = struct {
ast: Components,
volatile_token: ?TokenIndex,
outputs: []const Node.Index,
inputs: []const Node.Index,
pub const Components = struct {
asm_token: TokenIndex,
template: Node.Index,
items: []const Node.Index, // outputs ++ inputs, interleaved order preserved
clobbers: Node.OptionalIndex, // a comptime expression (the struct literal)
rparen: TokenIndex,
};
};
```
The on-disk extra record (`Ast.zig:3969-3975`) stores `items_start/items_end`
(a span into the node list), `clobbers` (optional node), and `rparen`.
### Parser — `lib/std/zig/Parse.zig`
`expectAsmExpr` (`Parse.zig:2771-2838`) implements the grammar:
```zig
fn expectAsmExpr(p: *Parse) !Node.Index {
const asm_token = p.assertToken(.keyword_asm);
_ = p.eatToken(.keyword_volatile);
_ = try p.expectToken(.l_paren);
const template = try p.expectExpr();
if (p.eatToken(.r_paren)) |rparen| { /* asm_simple */ }
_ = try p.expectToken(.colon);
// ... parse output items until a `:`/`)` ...
const clobbers: Node.OptionalIndex = if (p.eatToken(.colon)) |_| clobbers: {
// ... parse input items until a `:`/`)` ...
_ = p.eatToken(.colon) orelse break :clobbers .none;
break :clobbers (try p.expectExpr()).toOptional(); // clobbers = an expression
} else .none;
// ...
}
```
* `parseAsmOutputItem` (`Parse.zig:2840-2864`):
`LBRACKET IDENT RBRACKET STRINGLITERAL LPAREN (MINUSRARROW TypeExpr | IDENT) RPAREN`.
* `parseAsmInputItem` (`Parse.zig:2866-2883`):
`LBRACKET IDENT RBRACKET STRINGLITERAL LPAREN Expr RPAREN`.
* **Clobbers parse as a generic expression** (`(try p.expectExpr())`), not a
string list — this is the 0.16-dev change. It is later coerced to a
`std.lang.assembly.Clobbers` struct at Sema time.
### AST → ZIR — `lib/std/zig/AstGen.zig`
`asmExpr` (`AstGen.zig:8553-8669`) + `addAsm` (`12257-12310`). The ZIR payload
(`lib/std/zig/Zir.zig:2531-2564`):
```zig
pub const Asm = struct {
src_node: Ast.Node.Offset,
asm_source: NullTerminatedString, // template (string-literal case)
output_type_bits: u32, // bit i = output i uses `-> T` (vs ptr)
clobbers: Ref, // comptime ref → assembly.Clobbers value
pub const Small = packed struct(u16) { is_volatile: bool, outputs_len: u7, inputs_len: u8 };
pub const Output = struct { name: NullTerminatedString, constraint: NullTerminatedString, operand: Ref };
pub const Input = struct { name: NullTerminatedString, constraint: NullTerminatedString, operand: Ref };
};
```
AstGen already enforces the structural rules:
* Global (container-level) asm: rejects `volatile`, rejects any
outputs/inputs/clobbers (`AstGen.zig:8583-8587`).
* Local asm: **"assembly expression with no output must be marked volatile."**
* `outputs.len < 16`, `inputs.len < 32` (fit `Small.outputs_len`/`inputs_len`).
* At most one output may use the `-> T` form ("inline assembly allows up to one
output value"); `output_type_bits` records which.
* Two ZIR tags: `.@"asm"` (string-literal template) vs `.asm_expr` (comptime
expression template).
### ZIR → AIR (Sema) — `src/Sema.zig`
`zirAsm` (`Sema.zig:15044-15231`, dispatched at `1396-1397`). This is where all
*semantic* validation happens. It:
* Resolves the template to a comptime string (`resolveConstString`).
* **Global asm** (`func_index == .none`): asserts no operands, then
`zcu.addGlobalAssembly(owner, asm_source)` and returns `.void_value`.
* `requireRuntimeBlock` — local asm can't run at comptime.
* Per output: if `-> T`, resolve the type, `ensureLayoutResolved`, set the
expression's result type; else resolve the operand pointer. Validates:
* **output type has a well-defined in-memory layout** (else error);
* **cannot output to a `const` pointer** (`"asm cannot output to const '{s}'"`);
* output must be a runtime value (no reference to a comptime var).
* Per input: resolve operand, reject comptime-only refs, **coerce
`comptime_int`→`usize`, `comptime_float`→`f64`**.
* Clobbers: coerce the expression to `std.lang.assembly.Clobbers`, resolve to a
comptime value.
The AIR payload (`src/Air.zig:1485-1497`):
```zig
pub const Asm = struct {
source_len: u32,
inputs_len: u32,
clobbers: InternPool.Index, // comptime assembly.Clobbers value
flags: packed struct(u32) { outputs_len: u31, is_volatile: bool },
};
// trailing: out operand refs, in operand refs, then the template bytes and
// (constraint\0 name\0) pairs packed into air_extra.
```
### AIR → LLVM — `src/codegen/llvm/FuncGen.zig`
`airAssembly` (`FuncGen.zig:2473-2852`) is the crux. **This is the algorithm sx
must port.** Three sub-tasks:
**(a) Assemble the LLVM constraint string.** Comma-separated. For each output:
emit `=` (write-only) or `+` (read-write, recorded in `llvm_rw_vals`); a `*`
prefix marks an *indirect* (memory) output passed as a pointer parameter; a
non-indirect output contributes to the return type. The user's leading `=`/`+`
in `constraint[0]` is consumed and re-emitted; the rest is copied with Zig
commas rewritten to LLVM `|` (alternative constraints). Inputs are copied
similarly (no `=`). Clobbers: iterate the `Clobbers` struct's bool fields as a
bigint; for each `true` field emit `~{fieldname}` (via `appendConstraints`,
which also expands target-specific aliases).
**(b) Rewrite the template** `%[name]` → LLVM positional `${N}` (state machine,
`FuncGen.zig:2735-2802`):
| input | output | note |
|---|---|---|
| `$` | `$$` | escape LLVM's `$` |
| `%%` | `%` | literal percent |
| `%=` | `${:uid}` | unique id |
| `%[name]` | `${N}` | `N` = position in `name_map` |
| `%[name:mod]` | `${N:mod}` | with modifier |
`name_map` maps each operand's `[name]` to its positional index across all
outputs+inputs.
**(c) Build & call.** Pick the LLVM function type:
`return_count == 0` → `void`; `== 1` → the single return type; `> 1` → an
anonymous struct of the return types. Then:
```zig
const call = try self.wip.callAsm(
attributes, llvm_fn_ty,
.{ .sideeffect = is_volatile }, // Assembly.Info: sideeffect/alignstack/inteldialect/unwind
rendered_template, llvm_constraints, llvm_param_values, "");
```
`callAsm` (`lib/std/zig/llvm/Builder.zig:6131-6143`) is a thin wrapper that
builds the asm constant (`asmValue`) and emits a normal `call`. In LLVM-C terms
this is exactly `LLVMGetInlineAsm(...)` + `LLVMBuildCall2(...)`. Finally,
non-indirect outputs are read back: with one return it's the call result; with
several it's `extractvalue i` per output; indirect outputs were already written
by the asm via their pointer parameter.
### C backend — `src/codegen/c.zig`
No `airAssembly` for *inline* asm in the C backend in this tree; only global asm
flows out (as `module asm`). For sx this is irrelevant — sx only has an LLVM
backend.
### Global asm & naked functions
* **Global asm** bypasses everything above: `Sema.addGlobalAssembly` accumulates
the verbatim source; the LLVM object emits it via the module-level asm string
(LLVM-C: `LLVMAppendModuleInlineAsm`). Symbols it defines are reached with
`extern fn`.
* **Naked functions** (`callconv(.naked)`) drop the prologue/epilogue; the body
is entirely inline asm. This is an orthogonal calling-convention feature, not
part of the asm expression itself.
---
# PART II — Proposal for sx
## II.1 Design principles
1. **Copy Zig's *semantic* model exactly**: a template + register/memory operands
+ clobbers + a `volatile` flag; AT&T syntax via LLVM; "no-output asm must be
volatile"; `%[name]` substitution; AT&T-by-default.
2. **Copy the LLVM lowering exactly** (the constraint-string assembler + template
rewriter from `FuncGen.zig` are reproduced verbatim in §II.6 — these are the
parts where "inventing our own" would silently miscompile).
3. **Diverge from Zig's *surface* syntax where sx has a better-fitting idiom**, and
only there. The deviations (§II.2) are deliberate: a brace block instead of
`( … )`; `->`/`=` operand markers instead of positional `:` sections; an
enum-literal `clobbers(.…)` list; and — because sx has tuples and Zig does not —
**true multiple return values** instead of Zig's one-output cap.
## II.2 sx surface syntax
`asm` is an **expression** (it yields the output value/tuple), introduced by a new
`asm` keyword. The body is a **brace block** of comma-separated parts: a template
string first, then operands, then an optional `clobbers(.…)` clause. Each operand
is `[name]? "constraint" <role>`, where the role marker is:
* **`-> Type`** — an **output** that produces a value (joins the result).
* **`-> @place`** — an output that writes through to existing storage (Phase 2).
* **`= expr`** — an **input** (the value fed in).
`->` reuses sx's "produces" arrow (as in `(a: i32) -> i32`); `=` reuses sx's
"is set to" binding. There are no positional `:` sections.
```sx
// x86_64-linux — write(2) via syscall
sys_write :: (fd: i64, buf: [*]u8, len: u64) -> i64 {
return asm volatile {
"syscall",
"={rax}" -> i64, // output → the expression's value
"{rax}" = 1, // SYS_write
"{rdi}" = fd,
"{rsi}" = buf,
"{rdx}" = len,
clobbers(.rcx, .r11, .memory),
};
}
// read a register, no inputs, named operand for %[out]
sp :: () -> u64 {
return asm { "mov %%rsp, %[out]", [out] "=r" -> u64 };
}
```
Multi-instruction templates use sx's existing **`#string` heredoc**
(`src/lexer.zig:402`) or a multi-line `"..."` literal — no new lexer feature:
```sx
serialize :: () {
asm volatile {
#string ATT
mfence
lfence
ATT,
};
}
```
**Outputs and the result type.** A `-> Type` output contributes one value to the
asm expression's result; the count decides the shape:
| `-> Type` outputs | result | spelling |
|---|---|---|
| 0 | `void` (must be `volatile`) | `asm volatile { … }` |
| 1 | that type `T` | `x := asm { …, "=r" -> T };` |
| N | a **tuple** `(T1,…,Tn)` (declaration order) | `a, b := asm { … };` |
A `[name]` on an output becomes a **named tuple field** — the same name you'd use
for `%[name]` does double duty:
```sx
// sx has tuples, so asm gets real multiple return values (Zig caps you at one).
divmod :: (n: u64, d: u64) -> (quot: u64, rem: u64) {
return asm {
"divq %[d]",
[quot] "={rax}" -> u64, // → .quot (operand 0)
[rem] "={rdx}" -> u64, // → .rem (operand 1)
"{rax}" = n,
"{rdx}" = 0,
[d] "r" = d,
clobbers(.cc),
};
}
q, r := divmod(17, 5); // q = 3, r = 2
```
### Deviations from Zig (each deliberate; semantics unchanged)
* **[DEVIATION 1 — brace block, not `( … )`.]** The asm body is `asm { … }`, a
comma-separated brace block (trailing comma allowed, per `specs.md:226,501`),
not Zig's parenthesised form. Braces read as "a block of code," which is what an
asm template is; `#string` heredoc templates especially benefit. `asm` is a
keyword, so `asm {` / `asm volatile {` is unambiguous.
* **[DEVIATION 2 — `->`/`=` operand markers, not `:` sections.]** Zig groups
operands into positional `: outputs : inputs : clobbers` sections (count the
colons; `: :` for an empty one). sx tags each operand by role instead — `-> Type`
/ `-> @place` (output) and `= expr` (input) — so the list is flat,
order-independent, with no positional colons. *(`<-` for inputs was considered
and rejected: it can't be a global token without mis-lexing `a < -b`; `=` reuses
an existing token and the existing "binding" meaning.)*
* **[DEVIATION 3 — clobbers are an enum-literal list `clobbers(.cc, .memory)`.]**
Zig 0.16 uses a struct literal `: .{ .rcx = true }` coerced to a per-arch
`std.lang.assembly.Clobbers`; older Zig used a string list. sx uses a dot-literal
list, cleaner than both. **v1:** each `.name` is a dot-name lowered straight to
`~{name}` (`.memory`/`.cc` are recognized specials; register names pass through
verbatim; LLVM validates). **Phase 4:** upgrade `.name` to members of a
compile-time-checked per-arch `Clobber` enum — *same syntax*, gains typo-checking.
Note the call-looking `clobbers(…)` is a declarative clause, **not** a call —
nothing executes; it only feeds the register allocator.
* **[DEVIATION 4 — `volatile` is a *contextual* keyword.]** sx's keyword set
(`specs.md:168`) has neither `asm` nor `volatile`. `asm` becomes a real keyword;
`volatile` appears *only* right after `asm`, so it can be recognized contextually
(a plain identifier everywhere else), avoiding reserving it globally. The surface
is byte-identical to Zig. (Alternative: reserve globally — simpler lexer, small
source-compat risk. Recommend contextual.)
* **[DEVIATION 5 — multiple value-outputs return a tuple (sx ⊃ Zig).]** Zig allows
at most one `-> T` output; the rest must be pointer/lvalue outputs. sx has
tuples, so N `-> Type` outputs return `(T1,…,Tn)` (named when operands are
named), destructured with `a, b := …`. A deliberate *improvement* over Zig,
enabled by a feature Zig lacks, and maps onto LLVM's existing multi-output
struct return (§II.6). The other output flavor — `-> @place` write-through, plus
read-write (`"+r" -> @place`) and indirect-memory (`"=*m"`) outputs — is
**Phase 2** (needs indirect-constraint handling); the value-tuple form does not.
* **[DEVIATION 6 — global asm is a top-level `asm { … }` declaration.]** sx has no
namespace-level `comptime {}` block (it has `#run`, `specs.md:2598`), so global
asm is a top-level statement:
```sx
asm {
#string ATT
.global my_func
.type my_func, @function
my_func:
lea (%rdi,%rsi,1), %eax
retq
ATT,
};
my_func :: (a: i32, b: i32) -> i32 extern; // extern, no library — valid sx today
```
Only the `comptime {}` wrapper is dropped; lowers to `LLVMAppendModuleInlineAsm`.
**Calling the asm symbol reuses the C-FFI *import* path** (no new mechanism for
v1). A lib-less `extern` fn declaration (its library is optional; used in 50+
stdlib sites, e.g. `chdir :: (path: [*]u8) -> i32 extern;`) emits exactly the
artifact needed to *call into* the asm symbol — an external-linkage,
**C-calling-convention**, raw-named, link-time-resolved declaration — the same
thing Zig's `extern fn` produces (also C-callconv). The reverse direction (asm
calling *back into* an sx function) is handled by `export`, the define-and-expose
dual of `extern`.
Everything *semantic* — comptime-known template, register/memory constraints
verbatim to LLVM, clobber meaning, "no-output ⇒ must be volatile," AT&T default,
`%[name]`/`%%` substitution — is **identical to Zig**. Only the surface (block,
`->`/`=`, `clobbers(.…)`, tuple returns) differs.
## II.3 sx AST
sx's AST is a pointer-based tagged union (`Data = union(enum)` at
`src/ast.zig:13`, nodes built via `Parser.createNode`), much simpler than Zig's
SoA `extra_data` scheme — so we can store slices directly. Add one arm to the
`Node.Data` union (`src/ast.zig:13`):
```zig
// in Node.Data union(enum):
asm_expr: AsmExpr,
// new node struct, alongside the other expression node defs:
pub const AsmExpr = struct {
template: *Node, // string-literal / #string node (comptime string)
is_volatile: bool = false,
operands: []const AsmOperand, // declaration order preserved (= %N indexing)
clobbers: []const []const u8, // dot-names from clobbers(.…): "rcx","cc","memory"
};
pub const AsmOperand = struct {
name: ?[]const u8 = null, // optional [name]; only needed for %[name]
constraint: []const u8, // verbatim, e.g. "={rax}", "=r", "+r", "{rdi}", "r"
role: Role,
payload: *Node, // out_value → Type node; out_place/input → expr node
pub const Role = enum {
out_value, // `-> Type` value output; N of these → a tuple result
out_place, // `-> @place` write-through to existing storage (Phase 2)
input, // `= expr`
};
};
```
A single flat `operands` list (not split into outputs/inputs) preserves source
order — what the `%0`/`%[name]` indices and the LLVM constraint order key off. The
result type is derived in Sema from the `out_value` operands (§II.5).
## II.4 sx parser
`asm` is parsed in expression position. sx dispatches primary expressions in
`Parser.parsePrimary` (`src/parser.zig`); add a `.kw_asm` case (mirroring how
existing keyword/`#`-directive expressions like `#run` are handled):
1. consume `asm`; contextually consume `volatile` if the next token is the word
`volatile` (Deviation 4).
2. `expect(.l_brace)`; parse the first element as the **template** expression.
3. then a comma-separated list until `}`. Each element is either:
* an **operand** — `[name]?` (a bracketed identifier), a string-literal
constraint, then a role: `->` `Type` (out_value) · `->` `@`-place
(out_place, Phase 2) · `=` `expr` (input); or
* the **clobbers clause** — `clobbers` `(` `.`ident (`,` `.`ident)* `)`.
4. allow a trailing comma; `expect(.r_brace)`;
`createNode(start, .{ .asm_expr = … })`.
The first element is unambiguously the template (a string not followed by a role
marker). `->` vs `=` after the constraint disambiguates output vs input; inside a
`->` target, a leading `@` marks a write-through place vs a type.
Top-level/global asm (Deviation 6): recognize `asm {` at declaration scope and
build a dedicated `asm_global` decl (template only — reject operands/`volatile`).
Lexer/token: add `kw_asm` to the `Token.Tag` enum + keyword `StaticStringMap` in
`src/token.zig`; `volatile` and `clobbers` stay out of the global table
(contextual). **No new operator tokens** — `->` (`arrow`), `=` (`equal`), `.`
(`dot`) and `{}` already exist.
## II.5 sx Sema / typing
* **Result type** from the `out_value` operands (`-> Type`), in declaration order:
0 → `void` (and the asm **must** be `volatile`); 1 → that operand's type `T`;
N → a tuple `(T1,…,Tn)`, **named** when the operands carry `[name]`s
(`(name1: T1, …)`), positional otherwise. Implement in the expression typer
(`src/ir/expr_typer.zig` / wherever `inferExprType` lives), returning the resolved
`TypeId` (a tuple `TypeId` for N>1). **Do not** fall back to a silent default — an
unresolvable output type is a real error (CLAUDE.md silent-default rule): emit a
diagnostic and return the project's `.unresolved` sentinel.
* Port Zig's validation checklist (these are the user-facing error messages):
1. no output operand ⇒ the asm **must** be `volatile`;
2. each `out_value` result type must have a well-defined in-memory layout;
3. inputs must be runtime values; coerce comptime int→`i64`, float→`f64`;
4. template must be a comptime-known string;
5. (Phase 2) `out_place` cannot write a `const`; indirect-memory rules.
* Every `%[name]` referenced in the template must name an operand (best surfaced as
a Sema diagnostic; also caught at codegen during the rewrite — §II.6).
Note: there is **no** "≤1 output" rule (that was Zig's limit; sx's tuples lift it).
## II.6 sx IR + LLVM codegen (the part that must match Zig bit-for-bit)
### IR op — `src/ir/inst.zig`
Add to `Op = union(enum)` (`src/ir/inst.zig:80`), next to `objc_msg_send`
(`:219`). Strings are interned (`StringId`, as `const_string` at `:85`); operands
are SSA `Ref`s:
```zig
inline_asm: InlineAsm,
pub const InlineAsm = struct {
template: StringId, // interned, RAW (rewritten at emit)
operands: []const AsmOperand, // declaration order (= %N indexing)
clobbers: []const StringId, // interned dot-names: "rcx","cc","memory"
has_side_effects: bool,
// result rides on Inst.ty: void / a scalar TypeId / a tuple TypeId (N outputs)
};
pub const AsmOperand = struct {
role: enum { out_value, out_place, input },
name: StringId, // .none when unnamed
constraint: StringId, // verbatim "={rax}" / "=r" / "+r" / "{rdi}"
operand: Ref, // out_value → .none; out_place/input → the Ref
};
```
### Lowering — `src/ir/lower/expr.zig`
Add `.asm_expr => self.lowerAsmExpr(...)` to the `lowerExpr` dispatch. It interns
the template + constraint strings + clobber names, lowers each input operand to a
`Ref`, computes the result `TypeId` (§II.5), and emits the `inline_asm` op. (Same
shape as the existing `objc_msg_send` lowering.)
### Emit — `src/ir/emit_llvm.zig`
Add `.inline_asm => self.emitInlineAsm(...)` to the `emitInst` dispatch. This is a
**direct port of `FuncGen.airAssembly`**. Using the already-imported
`llvm_api.c`:
```zig
fn emitInlineAsm(self: *Emitter, inst: *const Inst, a: InlineAsm) void {
// 1) result LLVM type + param types/values from constraints
const ret_ty = self.lowerType(inst.ty); // void if no typed output
var param_tys: ...; var args: ...; // one per `input` constraint
// 2) assemble the LLVM constraint string (see algorithm below)
// outputs first ("=..."/"+..."), then inputs, then "~{reg}" clobbers, comma-joined
// 3) rewrite the template %[name]->${N}, %%->%, %=->${:uid}, $->$$ (state machine below)
const fn_ty = c.LLVMFunctionType(ret_ty, param_tys.ptr, n_params, 0);
const asm_val = c.LLVMGetInlineAsm(
fn_ty,
rendered_template.ptr, rendered_template.len,
constraint_str.ptr, constraint_str.len,
@intFromBool(a.has_side_effects), // HasSideEffects (volatile)
0, // IsAlignStack
c.LLVMInlineAsmDialectATT, // AT&T (Deviation: none — matches Zig default)
0, // CanThrow
);
const result = c.LLVMBuildCall2(self.builder, fn_ty, asm_val, args.ptr, n_params, "");
self.mapRef(inst, result); // 1 output: the value; N: extractvalue i per out_value → tuple
}
```
(Optionally cache the asm value keyed by `(template, constraints, fn_ty)` the way
`emit_llvm.zig:167` caches `objc_msg_send_value` — but per-site construction is
fine; LLVM uniques inline-asm constants internally.)
**Constraint-string assembler (port of `FuncGen.airAssembly`):**
```
parts = []
for op in operands where role == out_value or out_place: # outputs first
parts.append( op.constraint with ',' replaced by '|' ) # "={rax}", "=r", "+r" …
for op in operands where role == input:
parts.append( op.constraint with ',' replaced by '|' ) # "{rdi}", "r" …
for name in clobbers: # from clobbers(.name,…)
parts.append( "~{" + name + "}" ) # "~{rcx}", "~{cc}", "~{memory}"
constraint_str = ",".join(parts)
```
LLVM return type follows the `out_value` count: **0** → `void`; **1** → that type;
**N** → an anonymous struct `{T1,…,Tn}` — after the call, `extractvalue i` per
`out_value` builds the sx tuple (the multi-return path, §II.2 Dev 5). `out_place`
outputs are `store`d through their `Ref` afterward instead.
For `sys_write` (one output): constraint
`={rax},{rax},{rdi},{rsi},{rdx},~{rcx},~{r11},~{memory}`, `fn_ty = i64 (i64,ptr,i64)`,
`args = [1, fd, buf, len]`, `sideeffect = true`. For `divmod` (two outputs):
`={rax},={rdx},{rax},{rdx},r,~{cc}`, `fn_ty = {i64,i64} (i64,i64,i64)`, and the two
`extractvalue`s become the `(quot, rem)` tuple.
**Template rewriter (port verbatim from `FuncGen.zig:2735-2802`):** state machine
over the template bytes with a `name_map: [name] -> positional index` built from
`outputs ++ inputs`:
```
state start: '%' -> percent ; '$' -> emit "$$" ; else emit byte
state percent: '%' -> emit '%', start
'[' -> emit "${", state input
'=' -> emit "${:uid}", start
else -> emit '%', emit byte, start
state input: ']' -> emit name_map[name], emit '}', start
':' -> emit name_map[name], emit ':', state modifier
else accumulate name
state modifier:']' -> emit accumulated modifier, emit '}', start
else accumulate
```
An unknown `%[name]` is a hard error (mirror Zig's `todo`/diagnostic — **not** a
silent pass-through; CLAUDE.md no-silent-arms rule).
### Interpreter — `src/ir/interp.zig`
Inline asm cannot be comptime-evaluated. In the interpreter's op switch:
```zig
.inline_asm => return bailDetail("inline asm requires native execution; not available at comptime"),
```
(Same `bailDetail` pattern as the Obj-C/JNI ops — surfaces `op=inline_asm: ...`
rather than a silent default.)
### Global asm (Deviation 6)
Lower the top-level `asm_global` decl to a one-shot emit:
`c.LLVMAppendModuleInlineAsm(module, src.ptr, src.len)` (present in the linked
LLVM — `@19/include/llvm-c/Core.h:971`). No operands, no rewrite, no volatile;
multiple blocks concatenate in source order (as Zig does).
**Calling into an asm-defined symbol needs no new machinery** — declare it with a
lib-less `extern` (Deviation 6, §II.2): `my_func :: (sig) -> R extern;` emits
an external-linkage, raw-named, C-ABI extern that the linker resolves against the
`.global` the asm block defines.
**Guard (CLAUDE.md no-silent-arms):** a global-asm symbol exists only in the final
linked binary, not in the `#run`/JIT host process. The interpreter resolves
externs via `dlsym(RTLD_DEFAULT)` (`host_ffi.zig`), which won't find it — calling
such a symbol at comptime must fail **loudly** (it should already, via the
dlsym-miss diagnostic; pin it with a test). Edge case: a symbol referenced *only*
by other asm/external code may need `llvm.used` / `.no_dead_strip` to survive
dead-stripping; the common "sx references it" case is safe.
## II.7 Stage-to-file map (implementation checklist)
| Stage | Zig reference | sx file + insertion point | New code |
|---|---|---|---|
| Keyword | `tokenizer.zig` keywords | `src/token.zig` — `Token.Tag` + keyword `StaticStringMap` | `kw_asm` (+ contextual `volatile`) |
| AST node | `Ast.zig:2797,3789` | `src/ast.zig:13,85,721` — `Node.Data` + new `AsmExpr`/`AsmOperand` | ~25 lines |
| Parser | `Parse.zig:2771-2883` | `src/parser.zig` — `parsePrimary` `.kw_asm` case + global-asm at decl scope | ~120 lines |
| Sema/typing | `Sema.zig:15044` | `src/ir/expr_typer.zig` (`inferExprType`) + validation | ~80 lines |
| IR op | `Air.zig:1485`, `Zir.zig:2531` | `src/ir/inst.zig:80` — `inline_asm: InlineAsm` | ~25 lines |
| Lowering | `AstGen.zig:8553` | `src/ir/lower/expr.zig` — `lowerExpr` `.asm_expr` case | ~60 lines |
| LLVM emit | `FuncGen.zig:2473-2852` | `src/ir/emit_llvm.zig` — `emitInst` `.inline_asm` case | ~120 lines (constraint asm + template rewrite + `LLVMGetInlineAsm`/`BuildCall2`) |
| Global asm | `Sema.addGlobalAssembly` + `module asm` | decl lowering → `c.LLVMAppendModuleInlineAsm` | ~15 lines |
| Interp bail | n/a | `src/ir/interp.zig` op switch | 1 line |
No change to `src/codegen.zig` is needed (the IR/LLVM path owns this).
## II.8 Phasing
* **Phase 1 (MVP).** `asm { … }` block; `asm volatile`; string-literal/`#string`
template; `= expr` inputs; `-> Type` outputs **including N→tuple multi-return**;
`clobbers(.…)` dot-name list; `%[name]`/`%%` substitution; "no-output ⇒ volatile"
check; AT&T. Target: Linux/macOS `x86_64` + `aarch64` syscalls, intrinsics, and
multi-value ops (`divmod`, `cpuid`, `add_carry`).
* **Phase 2.** `-> @place` write-through outputs, read-write (`"+r" -> @place`) and
indirect-memory (`"=*m"`) constraints, `%=` unique-id, output-to-const rejection.
* **Phase 3.** Global/module asm decl (`LLVMAppendModuleInlineAsm`) + the
comptime-call guard, plus Intel-dialect opt-in. Small: the extern-call path
already exists (lib-less `extern`).
* **Phase 4 (optional).** Upgrade `clobbers(.name)` from dot-name sugar to a
compile-time-checked per-architecture `Clobber` enum (typo-checking; same syntax).
* **Phase 5 (optional).** Naked functions (`callconv`-equivalent) for full
freestanding entry points.
## II.9 Testing
asm output is target-specific, so tests must pin a target and assert on
emitted IR/exit, not run host-natively unless the host matches. Use the existing
corpus harness and the **`16xx` platform block** (the closest fit in the
`XXXX-category` scheme; `specs.md`/CLAUDE.md test-layout). Mirror Zig's own
matrix:
* `examples/16xx-platform-asm-syscall-write.sx` — x86_64-linux write(2), assert exit/stdout.
* `examples/16xx-platform-asm-register-read.sx` — `mov %%rsp,%[out]`, no-input output.
* `examples/16xx-platform-asm-no-output-volatile.sx` — bare `asm volatile { "nop" }`.
* `examples/16xx-platform-asm-missing-volatile.sx` — **expected compile error**
(no output, no volatile) — pins the diagnostic.
* `examples/16xx-platform-asm-template-subst.sx` — `%[a]`/`%%` rewriting, assert
on the `sx ir`/`.s` snapshot.
* `examples/16xx-platform-asm-multi-return.sx` — `divmod` → `(quot, rem)` tuple, destructured.
* `examples/16xx-platform-asm-global.sx` (Phase 3) — global asm + extern call.
Add an IR/`.s` snapshot (`expected/*.ir`) for the substitution test so the
constraint-string + template-rewrite output is locked. Seed markers and
regenerate with `zig build test -Dupdate-goldens`, then review the diff
(CLAUDE.md snapshot-integrity rule).
## II.10 Open decisions for the user
Largely settled through design review; what remains:
1. **Dialect:** AT&T only (Zig's default) for v1, or expose an Intel opt-in
(`LLVMInlineAsmDialectIntel`) from the start? **Recommend AT&T-only v1.**
2. **`volatile` keyword (Deviation 4):** contextual *(recommended, no
source-compat risk)* vs globally reserved *(simpler lexer)*.
3. **Brace separator:** comma *(recommended — trailing-comma-friendly,
literal-style)* vs `;` *(matches sx statement blocks)*.
4. **Asm-symbol extern spelling (Deviation 6): RESOLVED** — use the lib-less `extern`
keyword to call *into* an asm symbol (import), and `export` for the reverse
direction (an sx function asm can call *back into*). The dedicated linkage
keywords landed (FFI-linkage stream), so no new surface is needed and both
directions are covered.
*Decided:* brace block `{ … }` (Dev 1) · `->`/`=` markers, `:` sections dropped,
`<-` rejected (Dev 2) · `clobbers(.…)` enum-literal list, dot-name sugar now →
checked enum later (Dev 3) · multiple value-outputs return a tuple (Dev 5). For
global asm (Dev 6) the call-*into*-asm direction reuses lib-less `extern` (Decision
4, resolved).
## II.11 Risks
* **Constraint/template correctness is silent if wrong** — a bad constraint
string miscompiles with no diagnostic. Mitigation: port Zig's assembler/rewrite
verbatim (don't paraphrase) and lock IR snapshots in tests.
* **Register-name validity is unchecked** in v1's `clobbers(.name)` dot-name form —
a typo'd register (`.raxx`) surfaces only as an LLVM error. This is exactly the
gap the Phase-4 checked `Clobber` enum closes; acceptable for v1 (LLVM validates
the emitted `~{…}`).
* **`#string` heredoc + AT&T `%`/`$`** interplay: ensure the heredoc delivers the
template bytes literally (no sx-level escape processing of `%`/`$`) before the
rewrite stage.
* **Target gating:** asm examples must declare their target or they break the
corpus on other hosts; the test plan pins targets.
---
## Appendix A — exact LLVM-C calls (already reachable via `llvm_api.c`)
```c
// src/llvm_api.zig @cInclude("llvm-c/Core.h") exposes all of these:
LLVMValueRef LLVMGetInlineAsm(LLVMTypeRef Ty,
const char *AsmString, size_t AsmStringSize,
const char *Constraints, size_t ConstraintsSize,
LLVMBool HasSideEffects, LLVMBool IsAlignStack,
LLVMInlineAsmDialect Dialect, LLVMBool CanThrow); // LLVM 19 & 21: identical
LLVMValueRef LLVMBuildCall2(LLVMBuilderRef, LLVMTypeRef, LLVMValueRef Fn,
LLVMValueRef *Args, unsigned NumArgs, const char *Name);
void LLVMAppendModuleInlineAsm(LLVMModuleRef M, const char *Asm, size_t Len); // global asm
// enum: LLVMInlineAsmDialectATT, LLVMInlineAsmDialectIntel
```
## Appendix B — file index
**Zig (reference, `~/projects/zig`):** `lib/std/zig/tokenizer.zig` (keywords) ·
`lib/std/zig/Ast.zig:2797,3789,3969` (nodes) · `lib/std/zig/Parse.zig:2771-2883`
(grammar) · `lib/std/zig/AstGen.zig:8553-8669,12257` + `lib/std/zig/Zir.zig:2531`
(ZIR) · `src/Sema.zig:15044-15231` (validation) · `src/Air.zig:1485` (AIR) ·
`src/codegen/llvm/FuncGen.zig:2473-2852` + `lib/std/zig/llvm/Builder.zig:6131`
(LLVM) · `doc/langref/inline_assembly.zig`, `doc/langref/test_global_assembly.zig`
(syntax) · `doc/langref.html.in:4217-4300` (spec).
**sx (target, `~/projects/sx`):** `src/token.zig` · `src/lexer.zig:402` (#string) ·
`src/ast.zig:13` · `src/parser.zig` (`parsePrimary`), the optional `extern`
library tail · `src/ir/expr_typer.zig` · `src/ir/inst.zig:80,219,260` ·
`src/ir/lower/expr.zig` · `src/ir/module.zig:300` (`declareExtern`) ·
`src/ir/emit_llvm.zig:167` (msgSend cache), `:1244` (extern⇒C-ABI), `:1279`
(raw symbol name) · `src/ir/interp.zig` (`bailDetail`) · `src/llvm_api.zig:1-17` ·
`build.zig:10` (LLVM@19).
## Appendix C — Cookbook (final form: `asm { … }`, `->`/`=`, `clobbers(.…)`, pure AT&T)
```sx
// ── v1 ────────────────────────────────────────────────────────────────────
asm volatile { "nop" }; // bare side-effecting
// write(2) syscall — register-pinned inputs, one value-output
sys_write :: (fd: i64, buf: [*]u8, len: u64) -> i64 {
return asm volatile {
"syscall",
"={rax}" -> i64,
"{rax}" = 1, "{rdi}" = fd, "{rsi}" = buf, "{rdx}" = len,
clobbers(.rcx, .r11, .memory),
};
}
// mmap — full 6-arg syscall ABI (arg4 in r10, not rcx)
mmap :: (addr: *void, len: u64, prot: i32, flags: i32, fd: i32, off: i64) -> *void {
return asm volatile {
"syscall",
"={rax}" -> *void,
"{rax}" = 9, "{rdi}" = addr, "{rsi}" = len, "{rdx}" = prot,
"{r10}" = flags, "{r8}" = fd, "{r9}" = off,
clobbers(.rcx, .r11, .memory),
};
}
// AT&T scaled-index addressing — arr[i]
load_idx :: (arr: *i64, i: u64) -> i64 {
return asm {
"movq (%[arr],%[i],8), %[out]",
[out] "=r" -> i64, [arr] "r" = arr, [i] "r" = i,
};
}
// CPUID AVX probe — immediates, heavy clobber set, single value-result
has_avx :: () -> bool {
return asm volatile {
#string ATT
movl $1, %%eax
cpuid
andl $0x10000000, %%ecx
setne %[ok]
ATT,
[ok] "=r" -> bool,
clobbers(.rax, .rbx, .rcx, .rdx, .cc),
};
}
// SSE packed add — xmm regs, no outputs ⇒ volatile
vadd4 :: (a: *f32, b: *f32, out: *f32) {
asm volatile {
#string ATT
movups (%[a]), %%xmm0
movups (%[b]), %%xmm1
addps %%xmm1, %%xmm0
movups %%xmm0, (%[out])
ATT,
[a] "r" = a, [b] "r" = b, [out] "r" = out,
clobbers(.xmm0, .xmm1, .memory),
};
}
// ── multi-return (v1; sx has tuples, Zig caps at one output) ────────────────
// 64-bit divide → (quotient, remainder)
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),
};
}
// rdtsc → two 32-bit halves, destructured straight out of the asm
rdtsc :: () -> u64 {
lo, hi := asm volatile {
"rdtsc",
[lo] "={eax}" -> u32,
[hi] "={edx}" -> u32,
};
return (xx hi << 32) | xx lo;
}
// cpuid → a clean 4-tuple
cpuid :: (leaf: u32, subleaf: u32) -> (eax: u32, ebx: u32, ecx: u32, edx: u32) {
return asm volatile {
"cpuid",
[eax] "={eax}" -> u32, [ebx] "={ebx}" -> u32,
[ecx] "={ecx}" -> u32, [edx] "={edx}" -> u32,
"{eax}" = leaf, "{ecx}" = subleaf,
};
}
// add-with-carry → (sum, carry): value-output + tied input + flag capture
add_carry :: (a: u64, b: u64) -> (sum: u64, carry: u8) {
return asm {
#string ATT
addq %[b], %[sum]
setc %[carry]
ATT,
[sum] "=r" -> u64,
[carry] "=r" -> u8,
[a] "0" = a, [b] "r" = b,
clobbers(.cc),
};
}
// ── Phase 2 (write-through / read-write / indirect) ─────────────────────────
// byte memcpy — labels, loop, read-write operands
memcpy_bytes :: (dst: [*]u8, src: [*]u8, n: u64) {
d := dst; s := src; c := n;
asm volatile {
#string ATT
testq %[c], %[c]
jz 2f
1: movb (%[s]), %%al
movb %%al, (%[d])
incq %[s]
incq %[d]
decq %[c]
jnz 1b
2:
ATT,
[d] "+r" -> @d, [s] "+r" -> @s, [c] "+r" -> @c,
clobbers(.rax, .cc, .memory),
};
}
// lock cmpxchg CAS — lock prefix, pinned read-write rax, two outputs
cas :: (ptr: *i64, expected: i64, desired: i64) -> bool {
old := expected; ok: bool = ---;
asm volatile {
#string ATT
lock cmpxchgq %[desired], (%[ptr])
sete %[ok]
ATT,
[ok] "=r" -> @ok,
[old] "+{rax}" -> @old,
[ptr] "r" = ptr,
[desired] "r" = desired,
clobbers(.cc, .memory),
};
return ok;
}
// fill an existing struct (write-through, no tuple)
cpuid_into :: (out: *CpuId, leaf: u32) {
asm volatile {
"cpuid",
"={eax}" -> @out.eax, "={ebx}" -> @out.ebx,
"={ecx}" -> @out.ecx, "={edx}" -> @out.edx,
"{eax}" = leaf,
};
}
```
Global asm + extern (Phase 3):
```sx
asm {
#string ATT
.global my_add
my_add:
lea (%rdi,%rsi,1), %eax
retq
ATT,
};
my_add :: (a: i32, b: i32) -> i32 extern; // lib-less extern = Zig's `extern fn`
```

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

@@ -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

@@ -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

@@ -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

@@ -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;

View File

@@ -18,7 +18,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

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

View File

@@ -1,20 +1,20 @@
// M1.3 — `obj.class` accessor on Obj-C pointers.
//
// Any Obj-C-class pointer (foreign or sx-defined) can be probed
// Any Obj-C-class pointer (runtime or sx-defined) can be probed
// for its runtime class object via `obj.class`. Lowers to
// `object_getClass(obj)`. Returns `Class` (alias for *void —
// parameterized `Class(T)` covariance is M1.1.b).
//
// Verifies both shapes:
// 1. (*SxFoo).class — sx-defined class. Returns the SxFoo Class.
// 2. (*NSObject).class — foreign class via stdlib. Returns NSObject's
// 2. (*NSObject).class — runtime class via stdlib. Returns NSObject's
// Class.
#import "modules/std.sx";
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
NSObjectFwd :: #foreign #objc_class("NSObject") {
NSObjectFwd :: #objc_class("NSObject") extern {
alloc :: () -> *NSObjectFwd;
init :: (self: *NSObjectFwd) -> *NSObjectFwd;
}
@@ -33,7 +33,7 @@ main :: () -> i32 {
expected_f : Class = objc_getClass("SxFoo".ptr);
if cls_f != expected_f { print("FAIL: SxFoo.class mismatch\n"); return 1; }
// foreign class round-trip.
// runtime class round-trip.
nso := NSObjectFwd.alloc().init();
cls_n : Class = nso.class;
expected_n : Class = objc_getClass("NSObject".ptr);

View File

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

View File

@@ -14,7 +14,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
NSObject :: #foreign #objc_class("NSObject") {
NSObject :: #objc_class("NSObject") extern {
alloc :: () -> *NSObject;
init :: (self: *NSObject) -> *NSObject;
}

View File

@@ -1,4 +1,4 @@
// M2.2 (first pass) — `#property` directive on foreign-class
// M2.2 (first pass) — `#property` directive on runtime-class
// fields synthesizes Obj-C-runtime getter/setter dispatch.
//
// field: T #property[(modifiers)];
@@ -10,7 +10,7 @@
// of the field name. Modifiers (strong, weak, copy, readonly, ...)
// parse but don't yet drive ARC ops — that's Month 4.
//
// This slice covers FOREIGN-class properties. sx-defined property
// This slice covers RUNTIME-class properties. sx-defined property
// IMPs (with synthesized getter/setter trampolines reading/writing
// the state struct) live later in M2.2.
@@ -30,8 +30,8 @@ probe_set_tag :: (self: *void, _cmd: *void, v: i32) callconv(.c) {
g_probe_tag = v;
}
// Foreign declaration with #property on `tag`.
SxPropProbe :: #foreign #objc_class("SxPropProbe") {
// Extern declaration with #property on `tag`.
SxPropProbe :: #objc_class("SxPropProbe") extern {
alloc :: () -> *SxPropProbe;
init :: (self: *SxPropProbe) -> *SxPropProbe;
tag: i32 #property;

View File

@@ -21,7 +21,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
class_getInstanceMethod :: (cls: *void, sel: *void) -> *void #foreign objc;
class_getInstanceMethod :: (cls: *void, sel: *void) -> *void extern objc;
SxBox :: #objc_class("SxBox") {
width: i32 #property;

View File

@@ -1,6 +1,6 @@
// M2.3 — `#extends ForeignClass` method-resolution chaining.
// M2.3 — `#extends a runtime class` method-resolution chaining.
//
// When `obj.method()` is called on a foreign-class pointer and
// When `obj.method()` is called on a runtime-class pointer and
// `method` isn't declared directly on the receiver's class, the
// compiler walks the `#extends` chain to find an ancestor that
// declared it. The runtime dispatch path is unchanged —
@@ -12,13 +12,13 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
NSObjectBase :: #foreign #objc_class("NSObject") {
NSObjectBase :: #objc_class("NSObject") extern {
alloc :: () -> *NSObjectBase;
init :: (self: *NSObjectBase) -> *NSObjectBase;
hash :: (self: *NSObjectBase) -> u64;
}
// Sx-defined class that extends a foreign one. M1.2 registers
// Sx-defined class that extends a runtime one. M1.2 registers
// the class at module init; `hash` is reached via the M2.3 chain
// walk through NSObjectBase, then dispatched by objc_msgSend.
SxThing :: #objc_class("SxThing") {

View File

@@ -1,9 +1,9 @@
// `xx self` inside a BOOL-returning `#objc_class` method must
// resolve to the full receiver pointer at a foreign-class method
// resolve to the full receiver pointer at a runtime-class method
// call site, not get truncated to i8 by the enclosing function's
// BOOL return type. Regression locks in the
// `resolveCallParamTypes` fix that threads foreign-class method
// param types correctly even when the receiver is a `#foreign
// `resolveCallParamTypes` fix that threads runtime-class method
// param types correctly even when the receiver is a `extern
// #objc_class` alias. Every probe round-trips the receiver pointer
// — a regression would read only the low byte and the observer
// pointer would appear as e.g. 0xC0 / 0x20 instead of its real
@@ -14,10 +14,10 @@
g_observer : *void = null;
// Stand-in for NSNotificationCenter — we just need a foreign-class
// Stand-in for NSNotificationCenter — we just need a runtime-class
// method with several *void args so the call site's arg-target-type
// resolution exercises the same path as uikit.sx's keyboard observer.
SxIssue44Bus :: #foreign #objc_class("NSNotificationCenter") {
SxIssue44Bus :: #objc_class("NSNotificationCenter") extern {
defaultCenter :: () -> *SxIssue44Bus;
addObserver_selector_name_object :: (self: *Self, observer: *void, sel: *void, name: *void, obj: *void);
}
@@ -44,7 +44,7 @@ SxIssue44Foo :: #objc_class("SxIssue44Foo") {
// SxAppDelegate-shape: BOOL return + 2 extra *void args. Pre-fix,
// the call to addObserver:... would receive `xx this` truncated to
// its low byte (because resolveCallParamTypes returned `&.{}` for
// foreign-class receivers and `self.target_type` leaked the BOOL
// runtime-class receivers and `self.target_type` leaked the BOOL
// return type into the call's args).
appDelegate_options :: (this: *Self, app: *void, opts: *void) -> BOOL {
bus := SxIssue44Bus.defaultCenter();
@@ -87,7 +87,7 @@ main :: () -> i32 {
print("me: WRONG\n");
}
// The actual repro: BOOL return + foreign-class method call.
// The actual repro: BOOL return + runtime-class method call.
// Pre-fix: `xx this` truncated to i8, capture_observer receives
// (low_byte_of_f) cast back to *void, which won't equal f_void.
g_observer = null;

View File

@@ -16,7 +16,7 @@
NSPoint :: struct { x: f64; y: f64; }
// 16 B integer aggregate (Apple ARM64 — x0/x1 register pair, coerced
// via `[2 x i64]` in our foreign-decl path; same trip-up that
// via `[2 x i64]` in our extern-decl path; same trip-up that
// issue-0036 surfaced).
NSRange :: struct { location: u64; length: u64; }

View File

@@ -7,7 +7,7 @@
// Register a runtime-built Obj-C class with a method that returns
// a fixed `Triple`. The IMP is a plain sx fn (callconv .c) — its
// sret-shaped lowering already works (Phase 0.3 fix for plain
// `#foreign` returns). The `#objc_call` dispatch side now produces
// `extern` returns). The `#objc_call` dispatch side now produces
// the matching call shape: `call void @objc_msgSend(ptr sret %slot,
// ...)` + load. The two halves must agree on the ABI for the
// round-trip to return the right bytes.

View File

@@ -16,7 +16,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
SxProbeNiladic :: #foreign #objc_class("SxProbeNiladic") {
SxProbeNiladic :: #objc_class("SxProbeNiladic") extern {
length :: (self: *Self) -> i32;
}

View File

@@ -8,7 +8,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
SxProbeOneArg :: #foreign #objc_class("SxProbeOneArg") {
SxProbeOneArg :: #objc_class("SxProbeOneArg") extern {
addObject :: (self: *Self, val: i32) -> i32;
}

View File

@@ -7,7 +7,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
SxProbeMultiKeyword :: #foreign #objc_class("SxProbeMultiKeyword") {
SxProbeMultiKeyword :: #objc_class("SxProbeMultiKeyword") extern {
combine_and :: (self: *Self, a: i32, b: i32) -> i32;
}

View File

@@ -8,7 +8,7 @@
#import "modules/std.sx";
#import "modules/build.sx";
SxProbeMismatch :: #foreign #objc_class("SxProbeMismatch") {
SxProbeMismatch :: #objc_class("SxProbeMismatch") extern {
something_extra :: (self: *Self, x: i32) -> i32;
}

View File

@@ -6,7 +6,7 @@
//
// Mirrors JNI's static-dispatch surface (`Alias.new(...)` etc.); the
// lowering disambiguates static vs instance by looking at
// `method.is_static` on the foreign-class member.
// `method.is_static` on the runtime-class member.
//
// Uses NSObject because the cached class slot is populated by a
// constructor at module-load — runtime-created test classes wouldn't
@@ -15,7 +15,7 @@
#import "modules/std.sx";
#import "modules/build.sx";
NSObject :: #foreign #objc_class("NSObject") {
NSObject :: #objc_class("NSObject") extern {
// `+(Class)class` — niladic, name verbatim, selector = "class".
// Returns the class object itself. No `self: *Self` first param ⇒
// class method (sx parser keys on the param TYPE).

View File

@@ -13,7 +13,7 @@
#import "modules/std.sx";
#import "modules/build.sx";
NSObject :: #foreign #objc_class("NSObject") {
NSObject :: #objc_class("NSObject") extern {
// Default mangling would yield selector "gimme" — NSObject has no
// such IMP. The override pins it to the real selector
// "description". Static method (no `self: *Self` first param).
@@ -25,7 +25,7 @@ NSObject :: #foreign #objc_class("NSObject") {
// only on this side — main only invokes the static path because we
// don't have a real NSDictionary in scope, but the declaration locks
// in the parser + AST + lowering wiring for the multi-arg shape.
NSDictionary :: #foreign #objc_class("NSDictionary") {
NSDictionary :: #objc_class("NSDictionary") extern {
lookup :: (self: *Self, key: *void) -> *void #selector("objectForKey:");
}

View File

@@ -17,7 +17,7 @@
#import "modules/build.sx";
#import "modules/ffi/objc.sx";
SxManglingProbe :: #foreign #objc_class("SxManglingProbe") {
SxManglingProbe :: #objc_class("SxManglingProbe") extern {
length :: (self: *Self) -> i32;
addObject :: (self: *Self, a: i32) -> i32;
combine_and :: (self: *Self, a: i32, b: i32) -> i32;

View File

@@ -0,0 +1,28 @@
// Phase 3.0 (FFI-linkage) — postfix `extern` on an aggregate (`#objc_class`)
// is the new spelling of the legacy prefix `#objc_class(…) extern` import.
// Mirrors 1306's runtime-class chained dispatch with the new syntax:
// Name :: #objc_class("X") extern { … } == Name :: #objc_class(…) extern("X") { … }
//
// Red until 3.1 wires the postfix-extern aggregate path through the parser
// + lowering (maps `extern` → reference, same as `extern`).
#import "modules/std.sx";
#import "modules/build.sx";
NSObject :: #objc_class("NSObject") extern {
alloc :: () -> *NSObject;
init :: (self: *Self) -> *Self;
}
main :: () -> i32 {
inline if OS == .macos {
a := NSObject.alloc().init();
if a != null {
print("extern-class dispatch ok\n");
}
}
inline if OS != .macos {
print("extern-class dispatch ok\n");
}
0
}

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