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).
This commit is contained in:
agra
2026-06-15 08:06:05 +03:00
parent 8180faf839
commit 3811311e12
12 changed files with 69 additions and 340 deletions

View File

@@ -1575,167 +1575,3 @@ test "lower: scan populates source-keyed caches per declaring source (E0)" {
try std.testing.expect(global_k.value == k_a.value or global_k.value == k_b.value);
}
// ── GATE A→B: `extern`/`export` are a behavior-equivalent superset of `#foreign` ──
//
// The FFI-linkage stream replaces the prefix `#foreign` linkage modifier with the
// postfix `extern` (import) / `export` (define) keywords. Part B may not begin
// migrating `#foreign` call sites until this is locked: a sample fn / global /
// runtime-class written with `#foreign` must lower to byte-identical IR as the
// same decl written with `extern`. This test is the gate — keep it green.
//
// Phase 5.0 POST-FLIP NOTE: the fn-decl and data-global `#foreign` parser paths now
// build the *same* extern-named AST that postfix `extern` produces (commits e5ddfbe
// global, 6b94bb6 fn-body), so cases 1/2/4 below are STRUCTURALLY identical at the
// AST level — equivalence is guaranteed by construction, not coincidence. The test
// stays as a regression tripwire: if a future change re-diverges the two spellings
// (a reader that branches on `foreign_expr` structurally, or a revert of the flip),
// this gate catches it. Case 3 (runtime class) was always coalesced onto the single
// `is_foreign_eff` field, so it is behaviorally — not structurally — equal.
/// Lower a single self-contained `.sx` source (no stdlib imports) all the way to
/// the printed module IR text. Mirrors the full pipeline in the fix-0102b tests
/// above (parse → resolveImports → lowerRoot → printModule). Arena-allocated;
/// the caller's arena owns everything.
fn lowerSrcToIr(alloc: std.mem.Allocator, io: std.Io, src: []const u8) ![]const u8 {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = src });
var dirbuf: [4096]u8 = undefined;
const dirlen = try tmp.dir.realPath(io, &dirbuf);
const absdir = dirbuf[0..dirlen];
const main_path = try std.fmt.allocPrint(alloc, "{s}/main.sx", .{absdir});
const main_source = try alloc.dupeZ(u8, src);
var p = parser.Parser.init(alloc, main_source);
const root = p.parse() catch return error.ParseFailed;
var chain = std.StringHashMap(void).init(alloc);
var cache = imports.ModuleCache.init(alloc);
var import_graph = std.StringHashMap(std.StringHashMap(void)).init(alloc);
var flat_import_graph = std.StringHashMap(std.StringHashMap(void)).init(alloc);
const stdlib_paths = [_][]const u8{};
const mod = try imports.resolveImports(
alloc,
io,
root,
absdir,
main_path,
&chain,
&cache,
null,
null,
&stdlib_paths,
&import_graph,
&flat_import_graph,
.{},
);
var module_scopes = std.StringHashMap(std.StringHashMap(void)).init(alloc);
try module_scopes.put(main_path, mod.scope);
var cache_it = cache.iterator();
while (cache_it.next()) |entry| {
try module_scopes.put(entry.key_ptr.*, entry.value_ptr.scope);
}
var facts = try imports.buildImportFacts(alloc, main_path, mod, &cache);
const resolved_root = try alloc.create(Node);
resolved_root.* = .{ .span = root.span, .data = .{ .root = .{ .decls = mod.decls } } };
var module = ir_mod.Module.init(alloc);
// NOTE: no `defer module.deinit()` — the printed IR is duped into the arena
// before return, and the arena owns the rest; deiniting here is unnecessary.
var diagnostics = errors.DiagnosticList.init(alloc, main_source, main_path);
var lowering = Lowering.init(&module);
lowering.main_file = main_path;
lowering.resolved_root = resolved_root;
lowering.diagnostics = &diagnostics;
lowering.program_index.module_scopes = &module_scopes;
lowering.program_index.import_graph = &import_graph;
lowering.program_index.flat_import_graph = &flat_import_graph;
lowering.program_index.module_decls = &facts.decls;
lowering.lowerRoot(resolved_root);
if (diagnostics.hasErrors()) return error.LoweringFailed;
const print_mod = @import("print.zig");
var aw = std.Io.Writer.Allocating.init(alloc);
try print_mod.printModule(&module, &aw.writer);
const result = aw.writer.toArrayList();
return try alloc.dupe(u8, result.items);
}
test "lower: GATE A→B — #foreign and extern/export lower to identical IR" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
const io = lowerTestIo();
// 1. FUNCTION — bare libc-style import.
{
const foreign_ir = try lowerSrcToIr(alloc, io,
\\f :: (a: i32) -> i32 #foreign;
\\main :: () -> i32 { f(7) }
\\
);
const extern_ir = try lowerSrcToIr(alloc, io,
\\f :: (a: i32) -> i32 extern;
\\main :: () -> i32 { f(7) }
\\
);
try std.testing.expectEqualStrings(foreign_ir, extern_ir);
}
// 2. DATA GLOBAL — extern global.
{
const foreign_ir = try lowerSrcToIr(alloc, io,
\\g : *void #foreign;
\\main :: () -> i64 { xx g }
\\
);
const extern_ir = try lowerSrcToIr(alloc, io,
\\g : *void extern;
\\main :: () -> i64 { xx g }
\\
);
try std.testing.expectEqualStrings(foreign_ir, extern_ir);
}
// 2b. FUNCTION RENAME — sx name bound to a different C symbol (`extern_name` axis).
{
const foreign_ir = try lowerSrcToIr(alloc, io,
\\c_abs :: (a: i32) -> i32 #foreign "abs";
\\main :: () -> i32 { c_abs(-7) }
\\
);
const extern_ir = try lowerSrcToIr(alloc, io,
\\c_abs :: (a: i32) -> i32 extern "abs";
\\main :: () -> i32 { c_abs(-7) }
\\
);
try std.testing.expectEqualStrings(foreign_ir, extern_ir);
}
// 3. RUNTIME CLASS — Obj-C reference (import) class with dispatch.
{
const foreign_ir = try lowerSrcToIr(alloc, io,
\\C :: #foreign #objc_class("NSObject") {
\\ alloc :: () -> *C;
\\ init :: (self: *Self) -> *Self;
\\}
\\main :: () -> i32 { a := C.alloc().init(); if a != null { return 1; } 0 }
\\
);
const extern_ir = try lowerSrcToIr(alloc, io,
\\C :: #objc_class("NSObject") extern {
\\ alloc :: () -> *C;
\\ init :: (self: *Self) -> *Self;
\\}
\\main :: () -> i32 { a := C.alloc().init(); if a != null { return 1; } 0 }
\\
);
try std.testing.expectEqualStrings(foreign_ir, extern_ir);
}
}