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.
This commit is contained in:
agra
2026-06-14 13:39:05 +03:00
parent 235f74a8c9
commit 6932426c41
3 changed files with 78 additions and 40 deletions

View File

@@ -5,47 +5,52 @@ Companion to `current/PLAN-EXTERN-EXPORT.md` — one merged plan: **Part A** add
every commit, one step at a time per the cadence rule.
## Last completed step
**Phase 1.1** (green) — wired extern fn lowering in `decl.zig`; example **1223 now
green**, full suite green (634 corpus / 443 unit, 0 fail). A bare `extern` fn lowers
exactly like a lib-less `#foreign` import: `declare i32 @abs(i32) #0` — external
linkage, C ABI, NO `__sx_ctx` param; calls emit `call i32 @abs(i32 -7)` and resolve
against the default-linked libc. Six edits, all routing `extern` declare-only
(mirroring the `foreign_expr` guards): (1) `funcWantsImplicitCtx` suppresses ctx for
`.extern_`; (2) `declareFunction` adds `is_extern_decl` and (3) includes it in the
C-ABI promotion; (4) `lazyLowerFunction` routes `.extern_`→declare-only; (5)
`lowerFunction` declare-only guard; (6) `lowerFunctionBodyInto` never promotes/lowers
an extern stub. Hand-authored 1.0b snapshot matched byte-exact — no regen needed. No
`.ir` snapshot added (the trivial `declare i32 @abs(i32)` doesn't warrant a 1000-line
full-prelude dump; behavioral `.stdout` already catches ctx/linkage regressions).
**Phase 1.2d** (green) — **PHASE 1 COMPLETE.** `extern` data globals now parse + lower:
added a `kw_extern` branch in the var-decl-with-type-annotation path (`parser.zig:~451`,
beside `#foreign`) parsing `[LIB] ["csym"]` into `is_extern`/`extern_lib`/`extern_name`
(+ updated the trailing diagnostic to list `extern`); `registerTopLevelGlobal` now 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 init) for extern too.
Example **1225 green**`__stdinp : *void extern;` lowers to `@__stdinp = external
global ptr` and `@__stdinp` reads non-null. Full suite green (636 corpus / 443 unit, 0
fail). Kickoff exit criteria met: suite green; extern libc fn + global bindings run;
`#foreign` unregressed (all its examples pass → snapshots unchanged).
## 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` FUNCTIONS WORK** (import; bare form, no rename)
parse + lower complete, behavior-equivalent to a lib-less `#foreign` fn. Still TODO in
Phase 1: the `extern LIB "csym"` lib/rename axis (fields exist, unconsumed) and the
extern-global form. `export` not started (Phase 2). Part B `foreign` footprint to
purge: 643 lines / ~57 identifiers in `src/` + 28 doc lines. End-state invariant:
**zero `foreign`** (Phase 9.4 gate). **Done**: 0.0 tokens, 0.1 AST/parser plumbing,
1.0a fn-path parsing + lib/name fields, 1.0b xfail example, 1.1 fn lowering (green).
linking stays separate. **`extern` IS FULLY WORKING** (PHASE 1 DONE): functions
bare (`f :: (…) -> R extern;`) AND renamed (`extern [LIB] "csym"`); data globals —
bare (`g : T extern;`) AND renamed. All behavior-equivalent to the matching `#foreign`
form (external linkage, C ABI, no sx ctx). `extern_lib` is parsed + stored but is a
*reference* only — actual linking stays the `#library`/build-flag axis (same as
`#foreign`'s lib ref). `export` NOT started (Phase 2). Aggregates NOT started (Phase 3).
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 (bare
fn), 1224 (fn rename), 1225 (bare global).
## Next step
**Phase 1.2** (green) — two parts, each its own xfail→green or behavior-lock:
1. **`extern LIB "csym"` rename for fns** — extend `parseOptionalExternExport()` (or
`parseFnDecl`) to parse the optional `LIB` ident + `"csym"` string after the
keyword into `FnDecl.extern_lib`/`extern_name`; consume them in `declareFunction`
(mirror the `#foreign` c_name block at `decl.zig:~2119`: declare under the C name,
map sx→C in `foreign_name_map`/dedupe). New example renaming a libc symbol (e.g.
`c_abs :: (n: i32) -> i32 extern "abs";`).
2. **extern-global `g : T extern [LIB] ["csym"];`** — parse path at `parser.zig:425`
(the var-decl with type annotation): accept postfix `extern` → set
`VarDecl.is_extern`/`extern_lib`/`extern_name`; lower like the `#foreign` global
(`decl.zig:~1115-1137`, `.is_extern = vd.is_foreign`). New example mirroring
`examples/1205-ffi-foreign-global` with `extern`.
**SESSION STOP** — kickoff scope was Phases 01 only; Phase 1 is complete. Do NOT
start Phase 2 here. Next session picks up **Phase 2 — `export`** (define + expose):
fills the four export-gap conditions in `decl.zig` (all on the *define* path, not
`declareExtern`): (i) force `.external` linkage when `extern_export == .export_`
(`:2382`/`:2514`); (ii) promote to C ABI (`:2110`/the `lowerFunction` cc at `:2522`);
(iii) symbol-name override via `export "csym"` (already parsed into `extern_name`
just consume on the define path); (iv) ctx already suppressed for `.export_`? NO —
`funcWantsImplicitCtx` currently suppresses only `.extern_`; broaden to `!= .none`
(or add `.export_`). Start with an xfail multi-file test: an `export fn` called from a
companion `.c` caller. Then Phase 3 (aggregates), Phase 4 (interplay/diagnostics/docs
+ the A→B gate: unit test that `#foreign` and `extern` lower to identical IR).
Stop at end of Phase 1 (do NOT start Phase 2 `export` or Part B migration). Then the
A→B gate: a unit test that `#foreign` and `extern` lower to identical IR.
**Deferred (do in Phase 4):** (a) docs — `specs.md`/`readme.md` document
`extern`/`export` (the plan defers docs to Phase 4; `#foreign` stays documented until
the Part B cutover); (b) visibility-gate equivalence — bare `extern` (no `extern_lib`)
is currently unconditionally visible via the `c_import_bare` gate
(`decl.zig:~2241`, `fd.body.data != .foreign_expr → return true`), whereas a lib-less
`#foreign` is policed by `visibleOverEdges`. Single-file examples don't exercise this;
verify/align it at the A→B gate (a bare-extern lib-less fn should be policed like its
`#foreign` twin). Adding it now would be untested — needs a cross-module example.
## Open decisions
Part A ratified (bare / postfix / `⇒ callconv(.c)` / lib-separate). Part B (confirm
@@ -83,6 +88,10 @@ historical carve-out — keep `issues/*.md` provenance, gate the live tree only.
- (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.
## Known issues
- **Workflow hazard (1.2):** an editor format-on-save (or `zig fmt`) clobbered the

View File

@@ -1112,10 +1112,11 @@ pub fn registerTopLevelGlobal(self: *Lowering, vd: *const ast.VarDecl) void {
d.addFmt(.err, null, "top-level var '{s}' has no type annotation and no initializer to infer from", .{vd.name});
break :blk .void;
};
// Foreign globals reference a symbol defined in libSystem etc.
// (`_NSConcreteStackBlock : *void #foreign;`). The C symbol
// name is the optional override or the sx name itself.
const sym_name = vd.foreign_name orelse vd.name;
// Foreign / extern globals reference a symbol defined in libSystem etc.
// (`_NSConcreteStackBlock : *void #foreign;` or `… : *void extern;`). The C
// symbol name is the optional override (`extern_name`/`foreign_name`) or the
// sx name itself.
const sym_name = vd.extern_name orelse vd.foreign_name orelse vd.name;
const name_id = self.module.types.internString(sym_name);
const init_val = self.globalInitValue(vd, var_ty);
const gid = self.module.addGlobal(.{
@@ -1123,7 +1124,7 @@ pub fn registerTopLevelGlobal(self: *Lowering, vd: *const ast.VarDecl) void {
.ty = var_ty,
.init_val = init_val,
.is_const = false,
.is_extern = vd.is_foreign,
.is_extern = vd.is_foreign or vd.is_extern,
});
self.putGlobal(self.current_source_file, vd.name, .{ .id = gid, .ty = var_ty });
}
@@ -1137,7 +1138,7 @@ pub fn registerTopLevelGlobal(self: *Lowering, vd: *const ast.VarDecl) void {
/// is rejected with a diagnostic rather than silently zero-initialized — a
/// global has no run site for a dynamic initializer.
pub fn globalInitValue(self: *Lowering, vd: *const ast.VarDecl, var_ty: TypeId) ?inst_mod.ConstantValue {
if (vd.is_foreign) return null;
if (vd.is_foreign or vd.is_extern) return null;
const v = vd.value orelse return null;
return switch (v.data) {
.undef_literal => .zeroinit,

View File

@@ -449,7 +449,35 @@ pub const Parser = struct {
} });
}
return self.fail("expected ':', '=', ';' or '#foreign' after type annotation");
if (self.current.tag == .kw_extern) {
// name : type extern [LIB] ["csym"]; (extern data global — the
// extern-named counterpart of `#foreign`; resolved at link time)
self.advance();
var ext_lib: ?[]const u8 = null;
if (self.current.tag == .identifier) {
ext_lib = self.tokenSlice(self.current);
self.advance();
}
var ext_name: ?[]const u8 = null;
if (self.current.tag == .string_literal) {
const raw = self.tokenSlice(self.current);
ext_name = raw[1 .. raw.len - 1];
self.advance();
}
try self.expect(.semicolon);
return try self.createNode(start_pos, .{ .var_decl = .{
.name = name,
.name_span = name_span,
.type_annotation = type_node,
.value = null,
.is_extern = true,
.extern_lib = ext_lib,
.extern_name = ext_name,
.is_raw = name_is_raw,
} });
}
return self.fail("expected ':', '=', ';', '#foreign', or 'extern' after type annotation");
}
fn parseTypeExpr(self: *Parser) anyerror!*Node {