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.
This commit is contained in:
agra
2026-06-14 12:48:56 +03:00
parent bf6ef8370f
commit 62a3b46f6e
3 changed files with 100 additions and 14 deletions

View File

@@ -123,6 +123,14 @@ pub const Root = struct {
pub const CallingConvention = enum { default, c };
/// Linkage modifier written in the postfix slot after `callconv(...)`:
/// `name :: (sig) -> Ret [callconv(.x)] [extern | export] [;|{…}];`
/// `extern` = import (external linkage, C ABI, no sx ctx — `#foreign`'s role);
/// `export` = define + expose (body + external linkage + C ABI + no ctx).
/// Both imply `callconv(.c)`. Variants carry a trailing `_` to dodge the Zig
/// keywords. `.none` = no linkage modifier (the ordinary sx-internal decl).
pub const ExternExportModifier = enum { none, extern_, export_ };
pub const FnDecl = struct {
name: []const u8,
params: []const Param,
@@ -131,6 +139,10 @@ pub const FnDecl = struct {
type_params: []const StructTypeParam = &.{},
is_arrow: bool = false,
call_conv: CallingConvention = .default,
/// Postfix linkage modifier (`extern`/`export`) written after the
/// `callconv(...)` slot. `.none` for an ordinary sx-internal function.
/// Parsed in Phase 0.1; not consumed by the fn-decl path until Phase 1.
extern_export: ExternExportModifier = .none,
/// Span of the function's name token, for the reserved-type-name decl
/// diagnostic. Synthesized decls (e.g. `#import c` foreign
/// functions, lowering-time objc/protocol method synthesis) leave it zero.
@@ -343,6 +355,13 @@ pub const VarDecl = struct {
is_foreign: bool = false,
foreign_lib: ?[]const u8 = null,
foreign_name: ?[]const u8 = null,
/// `extern`-global form `g : T extern ["csym"];` — a reference to a global
/// defined elsewhere (external linkage, resolved at link time). The new
/// extern-named surface; distinct from the legacy `#foreign` path above.
/// `extern_name` is the optional symbol-name override. Parsed in Phase 0.1;
/// not consumed by the var-decl path until Phase 1.2.
is_extern: bool = false,
extern_name: ?[]const u8 = null,
/// True when the binding name was written as a backtick raw identifier
/// (`` `i2 := … ``). A raw name is exempt from the reserved-type-name
/// binding check.

View File

@@ -3680,6 +3680,25 @@ pub const Parser = struct {
return cc;
}
/// Postfix linkage modifier in the slot after `callconv(...)`:
/// `extern` (import) or `export` (define + expose), or `.none` if neither.
/// Mirrors `parseOptionalCallConv`. Bare-keyword today; the optional
/// `"csym"` symbol-name override lands in Phase 1.2/2.2. Defined here in
/// Phase 0.1 but NOT yet called from any decl path (wired in Phase 1.0).
fn parseOptionalExternExport(self: *Parser) ast.ExternExportModifier {
switch (self.current.tag) {
.kw_extern => {
self.advance();
return .extern_;
},
.kw_export => {
self.advance();
return .export_;
},
else => return .none,
}
}
fn isAssignOp(self: *const Parser) bool {
return switch (self.current.tag) {
.equal, .plus_equal, .minus_equal, .star_equal, .slash_equal, .percent_equal,
@@ -3975,6 +3994,48 @@ test "parse minimal main" {
try std.testing.expectEqual(@as(i64, 42), body.data.block.stmts[0].data.int_literal.value);
}
test "parseOptionalExternExport recognizes linkage keywords (unconsumed)" {
// Phase 0.1 plumbing: the helper exists and maps the keywords, but no
// decl path calls it yet (wired in Phase 1.0). Drive it directly.
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
{
var parser = Parser.init(arena.allocator(), "extern");
try std.testing.expectEqual(ast.ExternExportModifier.extern_, parser.parseOptionalExternExport());
}
{
var parser = Parser.init(arena.allocator(), "export");
try std.testing.expectEqual(ast.ExternExportModifier.export_, parser.parseOptionalExternExport());
}
{
var parser = Parser.init(arena.allocator(), "foo");
try std.testing.expectEqual(ast.ExternExportModifier.none, parser.parseOptionalExternExport());
}
}
test "extern/export AST fields default to absent (unconsumed)" {
// FnDecl.extern_export defaults to .none on a normally-parsed function;
// the fn-decl path does not consume the modifier until Phase 1.0.
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
var parser = Parser.init(arena.allocator(), "f :: () {}");
const root = try parser.parse();
const fd = root.data.root.decls[0].data.fn_decl;
try std.testing.expectEqual(ast.ExternExportModifier.none, fd.extern_export);
// VarDecl.is_extern / extern_name default to absent (no var-decl path
// consumes them until Phase 1.2). A struct literal locks field presence +
// defaults without depending on a top-level var form.
const vd: ast.VarDecl = .{
.name = "g",
.name_span = .{ .start = 0, .end = 0 },
.type_annotation = null,
.value = null,
};
try std.testing.expect(!vd.is_extern);
try std.testing.expect(vd.extern_name == null);
}
test "block value: trailing expr without `;` produces a value" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();