const std = @import("std"); const ast = @import("ast.zig"); const parser = @import("parser.zig"); const errors = @import("errors.zig"); const c_import = @import("c_import.zig"); const Node = ast.Node; /// Comptime evaluation context for the inline-if hoisting pass below. /// Mirrors the values `injectComptimeConstants` will later push into the /// lowering's `comptime_constants` map (OS / ARCH / POINTER_SIZE), but /// derived directly from the build target so we can resolve top-level /// `inline if OS == .X { ... }` arms before imports + lowering run. pub const ComptimeContext = struct { /// Lowercase OS name matching the OperatingSystem enum tag /// (macos / linux / windows / wasm / ios / android / unknown). os: []const u8 = "unknown", /// Lowercase architecture name matching the Architecture enum tag /// (aarch64 / x86_64 / wasm32 / wasm64 / unknown). arch: []const u8 = "unknown", /// 4 for wasm32, 8 for every other target. pointer_size: i64 = 8, }; /// Top-level `inline if OS == .X { decls }` blocks are parsed as /// `if_expr` / `match_expr` nodes in `root.decls`, but the lowering /// pass only knows how to dispatch on `.fn_decl` / `.const_decl` / /// `.var_decl` / etc. at decl positions — an `if_expr` at the top /// level is silently dropped. Same story for `#import` decls inside an /// `inline if` body: they need to be surfaced to the top so import /// resolution sees them. /// /// This pass walks `decls`, replaces every comptime conditional with /// the body of its taken arm (recursively flattened), and drops the /// rest. A condition we can't resolve at this stage is also dropped — /// the caller may want to surface that as a diagnostic later, but for /// the OS / ARCH / POINTER_SIZE patterns we cover here it shouldn't /// happen in practice. pub fn flattenComptimeConditionals(allocator: std.mem.Allocator, decls: []const *Node, ctx: ComptimeContext) std.mem.Allocator.Error![]const *Node { var out = std.ArrayList(*Node).empty; for (decls) |decl| { switch (decl.data) { .if_expr => |ie| { if (ie.is_comptime) { if (evalComptimeCondition(ie.condition, ctx)) |is_true| { const taken: ?*const Node = if (is_true) ie.then_branch else ie.else_branch; if (taken) |b| try appendBranchDecls(allocator, &out, b, ctx); continue; } // Couldn't evaluate — drop the whole conditional. This is // a conservative choice; future work may surface it as a // diagnostic. For OS / ARCH / POINTER_SIZE comparisons // the eval is total, so this shouldn't fire in practice. continue; } try out.append(allocator, decl); }, .match_expr => |me| { if (me.is_comptime) { if (evalComptimeMatch(&me, ctx)) |body| { try appendBranchDecls(allocator, &out, body, ctx); } continue; } try out.append(allocator, decl); }, else => try out.append(allocator, decl), } } return try out.toOwnedSlice(allocator); } fn appendBranchDecls(allocator: std.mem.Allocator, out: *std.ArrayList(*Node), branch: *const Node, ctx: ComptimeContext) std.mem.Allocator.Error!void { const stmts: []const *Node = if (branch.data == .block) branch.data.block.stmts else &[_]*Node{@constCast(branch)}; const recursed = try flattenComptimeConditionals(allocator, stmts, ctx); try out.appendSlice(allocator, recursed); } fn evalComptimeCondition(node: *const Node, ctx: ComptimeContext) ?bool { if (node.data != .binary_op) return null; const bo = &node.data.binary_op; if (bo.op != .eq and bo.op != .neq) return null; const name = switch (bo.lhs.data) { .identifier => |id| id.name, else => return null, }; if (std.mem.eql(u8, name, "OS") or std.mem.eql(u8, name, "ARCH")) { const variant = switch (bo.rhs.data) { .enum_literal => |el| el.name, else => return null, }; const target = if (std.mem.eql(u8, name, "OS")) ctx.os else ctx.arch; const matches = std.mem.eql(u8, variant, target); return if (bo.op == .eq) matches else !matches; } if (std.mem.eql(u8, name, "POINTER_SIZE")) { const rhs_val: i64 = switch (bo.rhs.data) { .int_literal => |il| il.value, else => return null, }; const matches = ctx.pointer_size == rhs_val; return if (bo.op == .eq) matches else !matches; } return null; } fn evalComptimeMatch(me: *const ast.MatchExpr, ctx: ComptimeContext) ?*const Node { const name = switch (me.subject.data) { .identifier => |id| id.name, else => return null, }; if (std.mem.eql(u8, name, "OS") or std.mem.eql(u8, name, "ARCH")) { const target = if (std.mem.eql(u8, name, "OS")) ctx.os else ctx.arch; for (me.arms) |arm| { const pattern = arm.pattern orelse continue; const variant = switch (pattern.data) { .enum_literal => |el| el.name, else => continue, }; if (std.mem.eql(u8, variant, target)) return arm.body; } for (me.arms) |arm| if (arm.pattern == null) return arm.body; return null; } if (std.mem.eql(u8, name, "POINTER_SIZE")) { for (me.arms) |arm| { const pattern = arm.pattern orelse continue; const rhs_val: i64 = switch (pattern.data) { .int_literal => |il| il.value, else => continue, }; if (ctx.pointer_size == rhs_val) return arm.body; } for (me.arms) |arm| if (arm.pattern == null) return arm.body; return null; } return null; } pub fn dirName(path: []const u8) []const u8 { var last_sep: usize = 0; var found = false; for (path, 0..) |ch, i| { if (ch == '/') { last_sep = i; found = true; } } return if (found) path[0..last_sep] else "."; } /// Resolve an import path. Tries (in order): /// 1. relative to `base_dir` (the importing file's directory) /// 2. relative to CWD, absolutified via `root_path` if supplied /// 3. relative to each path in `stdlib_paths` (the install-discovered stdlib) /// Returns the first path that exists. Falls back to the raw path if nothing matches /// so the caller's readFile produces a coherent "not found" error. pub fn resolveImportPath(allocator: std.mem.Allocator, io: std.Io, base_dir: []const u8, raw_path: []const u8, root_path: ?[]const u8, stdlib_paths: []const []const u8) ![]const u8 { if (!std.mem.eql(u8, base_dir, ".")) { const rel_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ base_dir, raw_path }); // Check if it exists as file relative to base_dir if (std.Io.Dir.readFileAlloc(.cwd(), io, rel_path, allocator, .limited(10 * 1024 * 1024))) |_| { return rel_path; } else |_| {} // Check if it exists as directory relative to base_dir if (std.Io.Dir.openDir(.cwd(), io, rel_path, .{})) |dir| { dir.close(io); return rel_path; } else |_| {} } // Try CWD-relative (absolutified if root_path is known). const cwd_candidate = if (root_path) |rp| blk: { if (rp.len > 0 and raw_path.len > 0 and raw_path[0] != '/') { break :blk try std.fmt.allocPrint(allocator, "{s}/{s}", .{ rp, raw_path }); } break :blk raw_path; } else raw_path; if (std.Io.Dir.readFileAlloc(.cwd(), io, cwd_candidate, allocator, .limited(10 * 1024 * 1024))) |_| { return cwd_candidate; } else |_| {} if (std.Io.Dir.openDir(.cwd(), io, cwd_candidate, .{})) |dir| { dir.close(io); return cwd_candidate; } else |_| {} // Try each stdlib search path. for (stdlib_paths) |sp| { const cand = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ sp, raw_path }); if (std.Io.Dir.readFileAlloc(.cwd(), io, cand, allocator, .limited(10 * 1024 * 1024))) |_| { return cand; } else |_| {} if (std.Io.Dir.openDir(.cwd(), io, cand, .{})) |dir| { dir.close(io); return cand; } else |_| {} } return cwd_candidate; } /// Discover candidate stdlib search paths from the running binary's location. /// Honors the `SX_STDLIB_PATH` env var as an explicit override. Returns a slice /// of absolute paths owned by the allocator. pub fn discoverStdlibPaths(allocator: std.mem.Allocator) ![]const []const u8 { var out = std.ArrayList([]const u8).empty; // Env override via libc getenv (cross-stdlib-version stable). if (c_getenv("SX_STDLIB_PATH")) |env_path| { try out.append(allocator, try allocator.dupe(u8, std.mem.span(env_path))); } const exe_path = selfExePath(allocator) catch return try out.toOwnedSlice(allocator); const exe_dir = dirName(exe_path); // Stdlib paths are directories containing a `modules/` subdir; the import // directive (e.g. `#import "modules/std.sx"`) supplies the rest. // Dev: zig-out/bin/sx -> repo-root/library try out.append(allocator, try std.fmt.allocPrint(allocator, "{s}/../../library", .{exe_dir})); // Install: /bin/sx -> /library try out.append(allocator, try std.fmt.allocPrint(allocator, "{s}/../library", .{exe_dir})); // Alongside the binary. try out.append(allocator, try std.fmt.allocPrint(allocator, "{s}/library", .{exe_dir})); if (c_getenv("SX_DEBUG_STDLIB") != null) { std.debug.print("[sx] exe_path={s}\n", .{exe_path}); for (out.items, 0..) |p, i| std.debug.print("[sx] stdlib_paths[{d}]={s}\n", .{ i, p }); } return try out.toOwnedSlice(allocator); } const builtin = @import("builtin"); extern "c" fn _NSGetExecutablePath(buf: [*]u8, len: *u32) c_int; extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8; fn c_getenv(name: [:0]const u8) ?[*:0]const u8 { return getenv(name.ptr); } fn selfExePath(allocator: std.mem.Allocator) ![]const u8 { var buf: [4096]u8 = undefined; switch (builtin.os.tag) { .macos, .ios => { var len: u32 = buf.len; if (_NSGetExecutablePath(&buf, &len) != 0) return error.PathBufferTooSmall; const span = std.mem.sliceTo(&buf, 0); return try allocator.dupe(u8, span); }, .linux => { const n = try std.posix.readlink("/proc/self/exe", &buf); return try allocator.dupe(u8, n); }, else => return error.UnsupportedHostOS, } } /// A resolved module: the fully-resolved declarations of a single .sx file, /// with its own scope tracking which names are defined. /// /// Imports are non-transitive. `scope` is intentionally *narrow*: it /// contains only the names of decls authored in THIS file (plus namespaced /// import aliases the file introduces). Visibility for names from /// flat-imported modules is computed at lookup time by joining the /// importer's `scope` with each direct flat-import's `scope` via /// `import_graph` — this lets cyclic imports (e.g. std.sx ↔ allocators.sx) /// resolve correctly even though one side of the cycle is skipped during /// `resolveImports` recursion. /// /// `decls` remains the full transitive flat list so the global lowering /// pass can resolve a body in B that calls into C even though A never /// imported C directly. pub const ResolvedModule = struct { path: []const u8, /// Full flat decl list: own decls + every transitively-imported module's /// own decls (deduped by name). Walked by `lowerRoot`/`scanDecls` so /// transitive callees stay resolvable when their callers are lowered. decls: []const *Node, /// Decls authored in this file. What flat importers of THIS module see /// (their visibility BFS joins these names in via `import_graph`). own_decls: []const *Node, /// Names authored in this file (plus namespace aliases this file /// introduces). Used as the per-file leaf in the visibility lookup; /// importers do NOT splice this into their own scope — they walk the /// import graph at query time instead. scope: std.StringHashMap(void), /// Add a declaration authored in this file. Updates scope + own_decls + /// the global flat decl list; dedups by name through `seen_list` (which /// already holds names previously appended via `mergeFlat`, so an /// authored decl that collides with a transitively-imported one stays /// out of the global list while still entering `own_decls` for /// importer-visibility purposes). pub fn addOwnDecl( self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), own_list: *std.ArrayList(*Node), seen_list: *std.StringHashMap(void), decl: *Node, ) !bool { var append_to_global = true; if (decl.data.declName()) |name| { if (self.scope.contains(name)) return false; try self.scope.put(name, {}); if (seen_list.contains(name)) { // A cross-module name collision: drop from the global list // (first-wins) UNLESS this is a per-source decl (a type, alias, // or non-function const), which must reach registration as a // distinct author of its own module. append_to_global = isPerSourceDecl(decl); } else { try seen_list.put(name, {}); } } if (append_to_global) try list.append(allocator, decl); try own_list.append(allocator, decl); return true; } /// Flat-import another module. The imported names are NOT added to /// `self.scope` — visibility joins per-file scopes at lookup time via /// `import_graph`. We only need to append `other.decls` (the full /// transitive list) to the global `list` so the lowering pass can /// still resolve transitively-imported callees. /// /// Deduped two ways: named decls by name (first-wins on cross-module /// collisions), and EVERY decl by node identity. The latter matters for /// anonymous decls — `impl` blocks have no `declName`, so under a diamond /// import the same cached node would otherwise be appended once per path /// and registered twice (e.g. `duplicate impl 'Into'`). pub fn mergeFlat( self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), seen_list: *std.StringHashMap(void), seen_nodes: *std.AutoHashMap(*Node, void), other: ResolvedModule, ) !void { _ = self; for (other.decls) |decl| { if (seen_nodes.contains(decl)) continue; if (decl.data.declName()) |name| { if (seen_list.contains(name)) { // First-wins on a cross-module name collision — EXCEPT a // per-source decl (a named type, or any non-function const: // type alias + value const), each of which must reach // registration as a distinct same-name author of its own // module (types and aliases; step E5 value consts). Only // FUNCTIONS keep first-wins (the shadowed author // stays reachable via its qualified name / SelectedFunc). // Node identity (above) still de-dups a diamond import of the // SAME decl. if (!isPerSourceDecl(decl)) continue; } else { try seen_list.put(name, {}); } } try seen_nodes.put(decl, {}); try list.append(allocator, decl); } } /// A decl that must register PER-SOURCE: each same-name author across modules /// registers against its OWN module rather than collapsing to a single /// first-wins winner. NAMED types and every non-function `const_decl` (type /// aliases + inline type decls + VALUE consts, source-keyed via the alias / /// const caches) are per-source — that is what prevents same-name collapse for /// types/aliases and supports same-name value consts (step E5). Everything /// else keeps the first-wins name-merge: FUNCTIONS (the shadowed /// author stays reachable via its qualified name / SelectedFunc), and crucially /// `var_decl`s, including a `#foreign` extern global declared in two files /// (e.g. `__stdinp : *void #foreign;`) that MUST resolve to the ONE libSystem /// symbol, not split into a duplicate `__stdinp.1`. fn isPerSourceDecl(decl: *const Node) bool { return switch (decl.data) { .struct_decl, .enum_decl, .union_decl, .error_set_decl, .protocol_decl, .foreign_class_decl => true, .const_decl => |cd| cd.value.data != .fn_decl, else => false, }; } /// Add another module as a namespaced import. The alias `name` becomes /// part of this module's own decls (so a flat-importer of this module /// sees the alias one hop out — matching authored names). /// /// Returns `false` (and adds nothing) when `name` already names a decl /// authored in THIS module — symmetric to `addOwnDecl`, so a namespace /// alias colliding with a prior same-module name is dropped rather than /// shadowing it. The caller surfaces the drop via `reportDuplicateName`; /// the reverse order (alias first, then a same-name authored decl) is /// caught by `addOwnDecl` seeing the alias already in `scope`. pub fn addNamespace( self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), own_list: *std.ArrayList(*Node), seen_list: *std.StringHashMap(void), name: []const u8, other: ResolvedModule, span: ast.Span, is_raw: bool, ) !bool { if (self.scope.contains(name)) return false; const ns_node = try allocator.create(Node); ns_node.* = .{ .span = span, .data = .{ .namespace_decl = .{ .name = name, .decls = other.decls, // The module's OWN authored decls — what `ns.fn` should bind // to. `decls` stays the full transitive list so // the lowering pass can still resolve transitive callees. .own_decls = other.own_decls, // The aliased module's resolved path (== the `resolved_path` // computed for this import). Retained for `buildImportFacts`. .target_module_path = other.path, // Carry the backtick raw escape from the `name :: #import …` // form so a reserved-name namespace is exempt from the decl // check, symmetric to every other decl site. .is_raw = is_raw, } }, }; try self.scope.put(name, {}); try seen_list.put(name, {}); try list.append(allocator, ns_node); try own_list.append(allocator, ns_node); return true; } pub fn finalize( self: *ResolvedModule, allocator: std.mem.Allocator, list: *std.ArrayList(*Node), own_list: *std.ArrayList(*Node), ) !void { self.decls = try list.toOwnedSlice(allocator); self.own_decls = try own_list.toOwnedSlice(allocator); } }; /// Module cache: maps resolved file paths to their ResolvedModules. pub const ModuleCache = std.StringHashMap(ResolvedModule); // ── Raw import facts (the unified-resolver store) ── // // `buildImportFacts` produces two source-keyed views over the resolved program, // callable WITHOUT IR lowering (the LSP reuses it later): a scalar per-module // raw-decl index (`name → RawDeclRef`) and the namespace import edges // (`importer → alias → NamespaceTarget`). Both are built from each module's // `own_decls` (the main module plus every cache entry). Function authors are // read out of `name → RawDeclRef` directly (`fnDeclOfRaw`), so there is no // separate function-only index. /// A named top-level declaration the resolver may select, kept as the raw AST /// node pointer (NOT pre-classified — a `const_decl` whose value is a function /// stays a `.const_decl`; classification is a later phase's job). `impl_block` /// is deliberately absent: it has no `declName` and is deduped by node identity /// (`mergeFlat`), so it never enters the scalar index. pub const RawDeclRef = union(enum) { fn_decl: *const ast.FnDecl, const_decl: *const ast.ConstDecl, var_decl: *const ast.VarDecl, struct_decl: *const ast.StructDecl, enum_decl: *const ast.EnumDecl, union_decl: *const ast.UnionDecl, error_set_decl: *const ast.ErrorSetDecl, protocol_decl: *const ast.ProtocolDecl, foreign_class_decl: *const ast.ForeignClassDecl, namespace_decl: *const ast.NamespaceDecl, }; /// A raw declaration paired with the source file that authors it. pub const RawAuthor = struct { raw: RawDeclRef, source: []const u8 }; /// One module's scalar raw-decl index: `name → ONE RawDeclRef`. Scalar because /// `addOwnDecl` refuses a same-module same-name second author (returns false), /// so a module's `own_decls` carries at most one author per name. Cross-module /// multiplicity lives one level up, keyed by path in `ModuleDecls`. pub const ModuleRawDeclIndex = struct { source: []const u8, names: std.StringHashMap(RawDeclRef) }; /// `path → ModuleRawDeclIndex`. Two modules each authoring `f` are retained /// under their own paths — the cross-module same-name authors the unified /// resolver's collector will surface. pub const ModuleDecls = std.StringHashMap(ModuleRawDeclIndex); /// One namespace import edge: `alias :: #import "…"` (or `alias :: #import c …`). /// `target_module_path` is captured at resolution time (otherwise lost — it is /// not derivable from the namespace node alone). Every alias is module surface /// under the carry rule — there is no per-edge visibility flag. pub const NamespaceTarget = struct { alias: []const u8, importer_source: []const u8, target_module_path: []const u8, own_decls: []const *Node, /// The `DeclId` of each member in `own_decls`, in slice order. Filled by /// `buildDeclTable` (empty until then). Lets a member be addressed by stable /// id without re-deriving it from the node pointer. member_ids: []const DeclId = &.{}, }; /// `importer_source → alias → NamespaceTarget`. pub const NamespaceEdges = std.StringHashMap(std.StringHashMap(NamespaceTarget)); /// The `RawDeclRef` a top-level node carries, or null when the node is not a /// selectable named declaration (e.g. `impl_block`, `ufcs_alias`, a flat /// `c_import_decl`). Public so the unified resolver's namespace collector /// can classify a `NamespaceTarget.own_decls` node without re-deriving the map. pub fn rawDeclRefOf(decl: *const Node) ?RawDeclRef { return switch (decl.data) { .fn_decl => |*d| .{ .fn_decl = d }, .const_decl => |*d| .{ .const_decl = d }, .var_decl => |*d| .{ .var_decl = d }, .struct_decl => |*d| .{ .struct_decl = d }, .enum_decl => |*d| .{ .enum_decl = d }, .union_decl => |*d| .{ .union_decl = d }, .error_set_decl => |*d| .{ .error_set_decl = d }, .protocol_decl => |*d| .{ .protocol_decl = d }, .foreign_class_decl => |*d| .{ .foreign_class_decl = d }, .namespace_decl => |*d| .{ .namespace_decl = d }, else => null, }; } /// Index one module's authored decls (`own_decls`) into `decls[path]` and record /// any namespace aliases into `ns_edges[path]`. First-wins WITHIN a module; /// `own_decls` is already name-deduped by `addOwnDecl`, so the first-wins guard /// never actually fires here. fn indexModuleDecls( allocator: std.mem.Allocator, decls: *ModuleDecls, ns_edges: *NamespaceEdges, path: []const u8, own_decls: []const *Node, ) !void { const gop = try decls.getOrPut(path); if (!gop.found_existing) gop.value_ptr.* = .{ .source = path, .names = std.StringHashMap(RawDeclRef).init(allocator) }; const index = gop.value_ptr; for (own_decls) |decl| { const ref = rawDeclRefOf(decl) orelse continue; const name = decl.data.declName() orelse continue; const name_gop = try index.names.getOrPut(name); if (!name_gop.found_existing) name_gop.value_ptr.* = ref; if (decl.data == .namespace_decl) { const ns = &decl.data.namespace_decl; const edge_gop = try ns_edges.getOrPut(path); if (!edge_gop.found_existing) edge_gop.value_ptr.* = std.StringHashMap(NamespaceTarget).init(allocator); const tgt_gop = try edge_gop.value_ptr.getOrPut(ns.name); if (!tgt_gop.found_existing) tgt_gop.value_ptr.* = .{ .alias = ns.name, .importer_source = path, .target_module_path = ns.target_module_path, .own_decls = ns.own_decls, }; } } } /// Build the raw import facts from a resolved program: the main module (keyed by /// `main_path`) plus every cached module (keyed by its own path). No IR lowering /// required. pub fn buildImportFacts( allocator: std.mem.Allocator, main_path: []const u8, main_mod: ResolvedModule, cache: *const ModuleCache, ) !struct { decls: ModuleDecls, ns_edges: NamespaceEdges } { var decls = ModuleDecls.init(allocator); var ns_edges = NamespaceEdges.init(allocator); try indexModuleDecls(allocator, &decls, &ns_edges, main_path, main_mod.own_decls); var it = cache.iterator(); while (it.next()) |entry| { try indexModuleDecls(allocator, &decls, &ns_edges, entry.key_ptr.*, entry.value_ptr.own_decls); } return .{ .decls = decls, .ns_edges = ns_edges }; } // ── DeclTable: a stable DeclId for every declaration (Fork C S1, additive) ── // // `buildDeclTable` lifts every `RawDeclRef` the import facts hold into a stable // `DeclId` carrying source + name + AST node identity + span + `DeclKind`. It is // built in PARALLEL with the old maps and nothing in lowering consumes it for // selection yet (S4 makes it the fact-store key), so generated IR + bytes are // unchanged by construction. /// The taxonomy of a declaration, mirroring the `RawDeclRef` variants so a /// `DeclTable` row carries its kind without re-switching on the AST node. pub const DeclKind = enum { function, constant, global, @"struct", @"enum", @"union", error_set, protocol, foreign_class, namespace, }; fn declKindOf(ref: RawDeclRef) DeclKind { return switch (ref) { .fn_decl => .function, .const_decl => .constant, .var_decl => .global, .struct_decl => .@"struct", .enum_decl => .@"enum", .union_decl => .@"union", .error_set_decl => .error_set, .protocol_decl => .protocol, .foreign_class_decl => .foreign_class, .namespace_decl => .namespace, }; } /// The AST node identity a `RawDeclRef` wraps — the inner decl pointer every /// variant holds (the same identity `resolver.zig` selects authors by). This is /// the key the `DeclTable` indexes and round-trips on. pub fn authorNodePtrOf(ref: RawDeclRef) usize { return switch (ref) { inline else => |p| @intFromPtr(p), }; } /// The `*const ast.StructDecl` a top-level decl node carries, or null when it is /// not a struct — a bare `struct_decl` or a `const_decl` whose value is one, /// both unwrapping to the same inner decl (mirrors lower's `structDeclOfRaw`). fn structDeclPtrOf(decl: *const Node) ?*const ast.StructDecl { return switch (decl.data) { .struct_decl => &decl.data.struct_decl, .const_decl => |cd| if (cd.value.data == .struct_decl) &cd.value.data.struct_decl else null, else => null, }; } /// A stable identifier for one declaration, assigned by `DeclTable` in module- /// walk order. Process-local: it indexes the table's `entries` (S5 stabilizes it /// to `(source, index)` for the LSP, per the deep-dive's R5). pub const DeclId = enum(u32) { _ }; /// One `DeclTable` row: a `RawDeclRef` lifted to a stable `DeclId`, with its /// authoring source path, display name, AST span, and `DeclKind`. `ref` is the /// same raw author the import facts hold (its AST node identity is `id`'s key). pub const DeclInfo = struct { id: DeclId, source: []const u8, name: []const u8, ref: RawDeclRef, span: ast.Span, kind: DeclKind, }; /// Stable `DeclId` for every source / namespaced / imported / C-imported decl. /// `entries` is indexed by `DeclId`; `by_node` reverse-maps the AST node /// identity (`authorNodePtrOf`) to its id; `by_struct` maps a generic struct's /// inner `*StructDecl` to its id (so a template registered during lowering can /// be keyed by `DeclId`). Borrowed by `ProgramIndex.decl_table`. pub const DeclTable = struct { alloc: std.mem.Allocator, entries: std.ArrayList(DeclInfo) = .empty, by_node: std.AutoHashMap(usize, DeclId), by_struct: std.AutoHashMap(usize, DeclId), pub fn init(alloc: std.mem.Allocator) DeclTable { return .{ .alloc = alloc, .by_node = std.AutoHashMap(usize, DeclId).init(alloc), .by_struct = std.AutoHashMap(usize, DeclId).init(alloc), }; } pub fn deinit(self: *DeclTable) void { self.entries.deinit(self.alloc); self.by_node.deinit(); self.by_struct.deinit(); } pub fn get(self: *const DeclTable, id: DeclId) DeclInfo { return self.entries.items[@intFromEnum(id)]; } /// The `DeclId` for an AST node (by its `RawDeclRef` identity), or null when /// the node never entered the table. pub fn declIdForRef(self: *const DeclTable, ref: RawDeclRef) ?DeclId { return self.by_node.get(authorNodePtrOf(ref)); } /// The `DeclId` for a generic struct template's inner `*StructDecl`, or null. pub fn declIdForStructDecl(self: *const DeclTable, sd: *const ast.StructDecl) ?DeclId { return self.by_struct.get(@intFromPtr(sd)); } /// Intern one top-level decl node, returning its (possibly pre-existing) /// `DeclId`. First-wins / diamond dedup by node identity, matching how the /// scalar import facts dedup. The caller guarantees `rawDeclRefOf(decl)` is /// non-null (so `declName` is too). fn intern(self: *DeclTable, source: []const u8, decl: *const Node) !DeclId { const ref = rawDeclRefOf(decl).?; const key = authorNodePtrOf(ref); if (self.by_node.get(key)) |existing| return existing; const id: DeclId = @enumFromInt(@as(u32, @intCast(self.entries.items.len))); try self.entries.append(self.alloc, .{ .id = id, .source = source, .name = decl.data.declName().?, .ref = ref, .span = decl.span, .kind = declKindOf(ref), }); try self.by_node.put(key, id); if (structDeclPtrOf(decl)) |sd| try self.by_struct.put(@intFromPtr(sd), id); return id; } fn internModule(self: *DeclTable, source: []const u8, own_decls: []const *Node) !void { for (own_decls) |decl| { if (rawDeclRefOf(decl) == null) continue; _ = try self.intern(source, decl); } } /// Debug cross-check (S1.1 acceptance): every `RawDeclRef` the import facts /// hold round-trips `RawDeclRef → DeclId → AST node ptr` back to the same /// node, with matching name. Asserts; call only under `builtin.mode == .Debug`. pub fn verifyRoundTrip(self: *const DeclTable, decls: *const ModuleDecls, ns_edges: *const NamespaceEdges) void { var mit = decls.iterator(); while (mit.next()) |m| { var nit = m.value_ptr.names.iterator(); while (nit.next()) |kv| { const ref = kv.value_ptr.*; const id = self.declIdForRef(ref) orelse @panic("DeclTable round-trip: module ref has no DeclId"); const info = self.get(id); std.debug.assert(authorNodePtrOf(info.ref) == authorNodePtrOf(ref)); std.debug.assert(std.mem.eql(u8, info.name, kv.key_ptr.*)); } } var nsit = ns_edges.iterator(); while (nsit.next()) |imp| { var ait = imp.value_ptr.valueIterator(); while (ait.next()) |target| { for (target.own_decls) |decl| { const ref = rawDeclRefOf(decl) orelse continue; const id = self.declIdForRef(ref) orelse @panic("DeclTable round-trip: ns member has no DeclId"); std.debug.assert(authorNodePtrOf(self.get(id).ref) == authorNodePtrOf(ref)); } } } } }; /// Build the `DeclTable` from the resolved program + the import facts: every /// module author (main + cache) interned first, then every namespace member /// (reusing the module author's id when it is also a module decl, minting a new /// id for a synthetic C-import member). `ns_edges` is updated in place so each /// `NamespaceTarget.member_ids` lists its members' ids. Built from the SAME /// modules `buildImportFacts` walks; no IR lowering required. pub fn buildDeclTable( allocator: std.mem.Allocator, main_path: []const u8, main_mod: ResolvedModule, cache: *const ModuleCache, decls: *const ModuleDecls, ns_edges: *NamespaceEdges, ) !DeclTable { var table = DeclTable.init(allocator); try table.internModule(main_path, main_mod.own_decls); var it = cache.iterator(); while (it.next()) |entry| { try table.internModule(entry.key_ptr.*, entry.value_ptr.own_decls); } var nsit = ns_edges.iterator(); while (nsit.next()) |imp| { var ait = imp.value_ptr.valueIterator(); while (ait.next()) |target| { var ids = std.ArrayList(DeclId).empty; for (target.own_decls) |decl| { if (rawDeclRefOf(decl) == null) continue; const id = try table.intern(target.target_module_path, decl); try ids.append(allocator, id); } target.member_ids = try ids.toOwnedSlice(allocator); } } if (builtin.mode == .Debug) table.verifyRoundTrip(decls, ns_edges); return table; } /// Surface a same-module duplicate top-level declaration as a hard error at an /// explicit name + span. `addOwnDecl` / `addNamespace` return `false` when the /// name is already in this module's scope and drop the second author; without /// this the drop is silent, and the scalar `ModuleRawDeclIndex` would lose an /// authored name with no diagnostic. fn reportDuplicateName(diagnostics: ?*errors.DiagnosticList, added: bool, name: []const u8, span: ast.Span) void { if (added) return; const diags = diagnostics orelse return; diags.addFmt(.err, span, "duplicate top-level declaration '{s}'", .{name}); } /// Stamp the DEFINING module path onto a function body node, so a later /// pack/comptime monomorphization can pin `current_source_file` to the body's /// own module and resolve its bare names in that module's visibility context /// — mirroring how a normally-declared function carries /// `Function.source_file`. Only top-level decl Nodes are otherwise stamped, so /// the body Node would carry no source; a null body source after this means a /// synthesized/sourceless decl (the monomorphizer then keeps its caller's /// context, the legitimate fall-open). fn stampFnBodySource(decl: *Node, file_path: []const u8) void { switch (decl.data) { .fn_decl => |fd| fd.body.source_file = file_path, .struct_decl => |sd| stampStructMethodSources(sd, file_path), // A parameterized protocol is instantiated cross-module; record its // defining path so the instantiation resolves method-signature types in // this module (E4). .protocol_decl => decl.data.protocol_decl.source_file = file_path, // An sx-defined `#objc_class` / `#jni_class`: its IMP trampolines are // emitted at lowering time (possibly from another module's context), so // record the defining path AND stamp each method body (E4). .foreign_class_decl => { decl.data.foreign_class_decl.source_file = file_path; stampForeignClassMethodSources(decl.data.foreign_class_decl, file_path); }, .const_decl => |cd| switch (cd.value.data) { .fn_decl => |fd| fd.body.source_file = file_path, // `List :: struct { … append :: (…) { … } }` — the methods of a // (possibly generic) struct are monomorphized in their template's // OWN module (the E4 instantiation source-pin), so their // bodies need the defining path stamped just like a top-level fn. .struct_decl => |sd| stampStructMethodSources(sd, file_path), .protocol_decl => cd.value.data.protocol_decl.source_file = file_path, .foreign_class_decl => { cd.value.data.foreign_class_decl.source_file = file_path; stampForeignClassMethodSources(cd.value.data.foreign_class_decl, file_path); }, else => {}, }, else => {}, } } /// Stamp the defining module path onto every method (and struct-level fn /// constant) body of a struct decl, so a generic-struct method monomorphized at /// a cross-module call site still pins to the module that declares it. fn stampStructMethodSources(sd: ast.StructDecl, file_path: []const u8) void { for (sd.methods) |m| { if (m.data == .fn_decl) m.data.fn_decl.body.source_file = file_path; } for (sd.constants) |c| { if (c.data == .const_decl and c.data.const_decl.value.data == .fn_decl) { c.data.const_decl.value.data.fn_decl.body.source_file = file_path; } } } /// Stamp the defining module path onto every bodied method of an sx-defined /// foreign class, so the method's sx body lowers in the class's own module. fn stampForeignClassMethodSources(fcd: ast.ForeignClassDecl, file_path: []const u8) void { for (fcd.members) |m| { if (m == .method) { if (m.method.body) |b| b.source_file = file_path; } } } /// `reportDuplicateName` keyed off a node whose `declName()` carries the name /// (the regular authored-decl sites; an `import_decl` has no `declName`, so a /// namespace alias must use `reportDuplicateName` with the alias directly). fn reportDuplicateDecl(diagnostics: ?*errors.DiagnosticList, added: bool, decl: *const Node) void { if (added) return; const name = decl.data.declName() orelse return; reportDuplicateName(diagnostics, added, name, decl.span); } pub fn resolveImports( allocator: std.mem.Allocator, io: std.Io, root: *Node, base_dir: []const u8, file_path: []const u8, chain: *std.StringHashMap(void), cache: *ModuleCache, source_map: ?*std.StringHashMap([:0]const u8), diagnostics: ?*errors.DiagnosticList, stdlib_paths: []const []const u8, import_graph: ?*std.StringHashMap(std.StringHashMap(void)), flat_import_graph: ?*std.StringHashMap(std.StringHashMap(void)), comptime_ctx: ComptimeContext, ) !ResolvedModule { // Record this file's edge set so `param_impl_map` lookups can filter // candidates by what's been imported from where. Populated as each // import resolves below; transitive closure computed on demand. if (import_graph) |g| { if (!g.contains(file_path)) { try g.put(file_path, std.StringHashMap(void).init(allocator)); } } // FLAT-only edge set: identical to `import_graph` but records ONLY bare // `#import "…"` edges (`imp.name == null`), never a namespaced // `ns :: #import "…"`. The bare-name disambiguation walks this to // decide which same-name authors a flat importer can actually reach. if (flat_import_graph) |g| { if (!g.contains(file_path)) { try g.put(file_path, std.StringHashMap(void).init(allocator)); } } var mod = ResolvedModule{ .path = file_path, .decls = &.{}, .own_decls = &.{}, .scope = std.StringHashMap(void).init(allocator), }; if (root.data != .root) { mod.decls = &.{}; return mod; } // Hoist top-level `inline if OS == .X { ... }` body decls (including // any `#import`s inside them) to the top level before resolution // proceeds. After this pass, the decl list contains no top-level // `if_expr` / `match_expr` nodes with `is_comptime = true`. const flat_decls = try flattenComptimeConditionals(allocator, root.data.root.decls, comptime_ctx); var decl_list = std.ArrayList(*Node).empty; var own_decl_list = std.ArrayList(*Node).empty; // Name set spanning every decl already appended to `decl_list` — used // by `mergeFlat` to dedupe across diamond imports now that `mod.scope` // is non-transitive and can no longer serve as the dedup key. var seen_in_list = std.StringHashMap(void).init(allocator); // Node-identity set for the same purpose, covering anonymous decls // (impl blocks) that carry no name to dedupe on. var seen_nodes = std.AutoHashMap(*Node, void).init(allocator); for (flat_decls) |decl| { if (decl.data == .c_import_decl) { // Resolve `#source` / `#include` paths through the same chain // as `#import`: importing-file's directory → CWD → stdlib // search paths. This lets sx-library modules ship their own // C helpers (e.g. the Android JNI insets bridge) without // forcing every consumer to vendor an identically-named copy. { const ci_pre = decl.data.c_import_decl; if (ci_pre.sources.len > 0) { var resolved = try allocator.alloc([]const u8, ci_pre.sources.len); for (ci_pre.sources, 0..) |raw_src, idx| { resolved[idx] = try resolveImportPath(allocator, io, base_dir, raw_src, null, stdlib_paths); } decl.data.c_import_decl.sources = resolved; } if (ci_pre.includes.len > 0) { var resolved = try allocator.alloc([]const u8, ci_pre.includes.len); for (ci_pre.includes, 0..) |raw_inc, idx| { resolved[idx] = try resolveImportPath(allocator, io, base_dir, raw_inc, null, stdlib_paths); } decl.data.c_import_decl.includes = resolved; } } const ci = decl.data.c_import_decl; // Parse headers to get synthetic function declarations const result = c_import.processCImport( allocator, ci.includes, ci.defines, ci.flags, ) catch |err| { if (diagnostics) |diags| { diags.addFmt(.err, decl.span, "#import c failed: {}", .{err}); } return error.ImportError; }; if (ci.name) |ns_name| { // Namespaced: wrap fn_decls + c_import_decl in a namespace var ns_decls = std.ArrayList(*Node).empty; for (result.fn_decls) |fd| { try ns_decls.append(allocator, fd); } // Keep c_import_decl inside namespace so codegen can find sources try ns_decls.append(allocator, decl); const ns_slice = try ns_decls.toOwnedSlice(allocator); const ns_node = try allocator.create(Node); ns_node.* = .{ .span = decl.span, .data = .{ .namespace_decl = .{ .name = ns_name, .decls = ns_slice, // A C-import namespace authors exactly the wrapped fn // decls — they ARE its own decls. .own_decls = ns_slice, // No separate sx module: the synthesized members are // authored in THIS file. Record the importer's path. .target_module_path = file_path, .is_raw = ci.is_raw, } }, }; ns_node.source_file = file_path; if (mod.scope.contains(ns_name)) { reportDuplicateName(diagnostics, false, ns_name, decl.span); } else { try mod.scope.put(ns_name, {}); try seen_in_list.put(ns_name, {}); try decl_list.append(allocator, ns_node); try own_decl_list.append(allocator, ns_node); } } else { // Flat: add fn_decls directly + keep c_import_decl for (result.fn_decls) |fd| { fd.source_file = file_path; reportDuplicateDecl(diagnostics, try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, fd), fd); } decl.source_file = file_path; reportDuplicateDecl(diagnostics, try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl), decl); } continue; } if (decl.data != .import_decl) { decl.source_file = file_path; stampFnBodySource(decl, file_path); reportDuplicateDecl(diagnostics, try mod.addOwnDecl(allocator, &decl_list, &own_decl_list, &seen_in_list, decl), decl); continue; } const imp = decl.data.import_decl; const resolved_path = try resolveImportPath(allocator, io, base_dir, imp.path, null, stdlib_paths); // Record direct-import edge file_path → resolved_path. Self-imports // and chain duplicates are still recorded so the graph reflects what // the user wrote (filter happens at lookup). if (import_graph) |g| { if (g.getPtr(file_path)) |set| { set.put(resolved_path, {}) catch {}; } } // The same edge, FLAT-only: recorded only for a bare `#import` // (`imp.name == null`), excluding a namespaced `ns :: #import`. Covers // both a flat file import and a flat directory import (`resolved_path` // is the directory in the latter case). if (imp.name == null) { if (flat_import_graph) |g| { if (g.getPtr(file_path)) |set| { set.put(resolved_path, {}) catch {}; } } } // Circular import check — only along the current chain if (chain.contains(resolved_path)) continue; // Resolve or retrieve the imported module const imported_mod = if (cache.get(resolved_path)) |cached| cached else blk: { // Try as file first if (std.Io.Dir.readFileAlloc(.cwd(), io, resolved_path, allocator, .limited(10 * 1024 * 1024))) |imp_bytes| { const imp_source = try allocator.dupeZ(u8, imp_bytes); if (source_map) |sm| { sm.put(resolved_path, imp_source) catch {}; } var p = parser.Parser.init(allocator, imp_source); const imp_root = p.parse() catch { if (diagnostics) |diags| { diags.addFmt(.err, decl.span, "parse error in '{s}': {s}", .{ resolved_path, p.err_msg orelse "unknown" }); } return error.ImportError; }; // Push onto chain before recursing, pop after try chain.put(resolved_path, {}); const imp_dir = dirName(resolved_path); const result = try resolveImports(allocator, io, imp_root, imp_dir, resolved_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, flat_import_graph, comptime_ctx); _ = chain.remove(resolved_path); // Cache try cache.put(resolved_path, result); break :blk result; } else |_| { // File read failed — try as directory import. An extensionless // path that names a directory next to a same-named `.sx` file // is ambiguous: require the explicit `.sx` spelling for the // file rather than silently picking the directory. Exception: // when the sibling `.sx` is the importing file itself (a test // importing its own companion directory), the directory is the // only sensible target. const sibling_sx = try std.fmt.allocPrint(allocator, "{s}.sx", .{resolved_path}); const sibling_exists = if (std.mem.eql(u8, sibling_sx, file_path)) false else if (std.Io.Dir.readFileAlloc(.cwd(), io, sibling_sx, allocator, .limited(10 * 1024 * 1024))) |_| true else |_| false; if (sibling_exists) { const is_dir = if (std.Io.Dir.openDir(.cwd(), io, resolved_path, .{})) |d| dir_blk: { d.close(io); break :dir_blk true; } else |_| false; if (is_dir) { if (diagnostics) |diags| { diags.addFmt(.err, decl.span, "ambiguous import '{s}': both a file '{s}.sx' and a directory '{s}' exist — write \"{s}.sx\" to import the file", .{ imp.path, imp.path, imp.path, imp.path }); } return error.ImportError; } } const result = resolveDirectoryImport(allocator, io, resolved_path, chain, cache, source_map, diagnostics, decl.span, stdlib_paths, import_graph, flat_import_graph, comptime_ctx) catch { if (diagnostics) |diags| { diags.addFmt(.err, decl.span, "cannot read import '{s}' (not a file or directory)", .{resolved_path}); } return error.ImportError; }; try cache.put(resolved_path, result); break :blk result; } }; if (imp.name) |ns_name| { const added = try mod.addNamespace(allocator, &decl_list, &own_decl_list, &seen_in_list, ns_name, imported_mod, decl.span, imp.is_raw); reportDuplicateName(diagnostics, added, ns_name, decl.span); } else { try mod.mergeFlat(allocator, &decl_list, &seen_in_list, &seen_nodes, imported_mod); } } try mod.finalize(allocator, &decl_list, &own_decl_list); return mod; } /// Resolve a directory import by aggregating all .sx files in the directory. fn resolveDirectoryImport( allocator: std.mem.Allocator, io: std.Io, dir_path: []const u8, chain: *std.StringHashMap(void), cache: *ModuleCache, source_map: ?*std.StringHashMap([:0]const u8), diagnostics: ?*errors.DiagnosticList, span: ast.Span, stdlib_paths: []const []const u8, import_graph: ?*std.StringHashMap(std.StringHashMap(void)), flat_import_graph: ?*std.StringHashMap(std.StringHashMap(void)), comptime_ctx: ComptimeContext, ) anyerror!ResolvedModule { // Open the directory with iteration capability const dir = std.Io.Dir.openDir(.cwd(), io, dir_path, .{ .iterate = true }) catch { return error.ImportError; }; defer dir.close(io); // Collect all .sx file names var file_names = std.ArrayList([]const u8).empty; var it = dir.iterate(); while (it.next(io) catch null) |entry| { if (entry.kind != .file) continue; if (!std.mem.endsWith(u8, entry.name, ".sx")) continue; const name_copy = try allocator.dupe(u8, entry.name); try file_names.append(allocator, name_copy); } // Sort alphabetically for deterministic ordering std.mem.sort([]const u8, file_names.items, {}, struct { fn lessThan(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } }.lessThan); // Add directory to chain for circular import detection try chain.put(dir_path, {}); defer _ = chain.remove(dir_path); // Merge all files into a combined module. From an importer's perspective // a directory is one big module: the combined module's `own_decls` is // the union of every file's `own_decls`, so flat-importing the directory // exposes everything the files themselves authored — but not what those // files transitively imported from outside the directory. var combined = ResolvedModule{ .path = dir_path, .decls = &.{}, .own_decls = &.{}, .scope = std.StringHashMap(void).init(allocator), }; var decl_list = std.ArrayList(*Node).empty; var own_decl_list = std.ArrayList(*Node).empty; var seen_in_list = std.StringHashMap(void).init(allocator); var seen_nodes = std.AutoHashMap(*Node, void).init(allocator); for (file_names.items) |file_name| { const file_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir_path, file_name }); if (chain.contains(file_path)) continue; const file_mod = if (cache.get(file_path)) |cached| cached else file_blk: { const imp_bytes = std.Io.Dir.readFileAlloc(.cwd(), io, file_path, allocator, .limited(10 * 1024 * 1024)) catch { if (diagnostics) |diags| { diags.addFmt(.err, span, "cannot read '{s}' in directory import", .{file_path}); } return error.ImportError; }; const imp_source = try allocator.dupeZ(u8, imp_bytes); if (source_map) |sm| { sm.put(file_path, imp_source) catch {}; } var p = parser.Parser.init(allocator, imp_source); const imp_root = p.parse() catch { if (diagnostics) |diags| { diags.addFmt(.err, span, "parse error in '{s}': {s}", .{ file_path, p.err_msg orelse "unknown" }); } return error.ImportError; }; try chain.put(file_path, {}); const result = try resolveImports(allocator, io, imp_root, dir_path, file_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, flat_import_graph, comptime_ctx); _ = chain.remove(file_path); try cache.put(file_path, result); break :file_blk result; }; // Source-order matters: a file's own decls (e.g. `impl Foo` blocks) // may reference types defined in OTHER files that THIS file imports. // `file_mod.decls` already lists transitive-imported decls before // the file's own decls (resolveImports processes `#import` lines in // source order, and #imports usually come first), so iterating it // directly preserves the scan order the lowering pass needs to // register `Event` (a tagged_union) before `handle_event(e: *Event)` // triggers the placeholder-struct fallback in `resolveTypeName`. for (file_mod.decls) |decl| { if (seen_nodes.contains(decl)) continue; if (decl.data.declName()) |name| { if (seen_in_list.contains(name)) continue; try seen_in_list.put(name, {}); } try seen_nodes.put(decl, {}); try decl_list.append(allocator, decl); } // Separately track which decls the directory `re-exports` to its // flat-importers. Position in `own_decl_list` doesn't matter — it's // only consumed by the importer-side visibility join (`isNameVisible` // in lower.zig) which treats it as a set. for (file_mod.own_decls) |decl| { if (decl.data.declName()) |name| { if (combined.scope.contains(name)) continue; try combined.scope.put(name, {}); } try own_decl_list.append(allocator, decl); } } try combined.finalize(allocator, &decl_list, &own_decl_list); return combined; }