From 721369a71187b9dd744b83e64ece142897e0d227 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 11 Jun 2026 18:47:16 +0300 Subject: [PATCH] =?UTF-8?q?lang:=20fn=20aliases=20dispatch=20like=20their?= =?UTF-8?q?=20target=20(fix=200121)=20=E2=80=94=20scan-time=20registration?= =?UTF-8?q?=20through=20the=20shared=20alias-chain=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed fn aliases failed for EVERY kind (the filed pack-only scope was a same-name confound: same-name re-exports already resolved through the name-keyed fn_ast_map). scanDecls now follows ident-/ns.X-RHS const alias chains (aliasedFnDecl; 0120's hop walk extracted as followAliasChain) and registers the alias name in fn_ast_map (absent-only), so every dispatch path — early pack/comptime/generic, plain lazy-lower, plan-side typing — sees the target decl unchanged. my_print :: s.print; / my_format :: s.format; now work (the std.sx re-export shape). Regression: examples/0546 (+rich). Gates: zig build test 0, suite 588/588. --- examples/0546-packs-fn-alias-rich.sx | 6 + examples/0546-packs-fn-alias.sx | 35 ++++++ examples/expected/0546-packs-fn-alias.exit | 1 + examples/expected/0546-packs-fn-alias.stderr | 1 + examples/expected/0546-packs-fn-alias.stdout | 6 + issues/0121-pack-fn-alias-unresolved.md | 112 +++++++++++++++++++ readme.md | 7 +- specs.md | 17 +++ src/ir/lower.zig | 1 + src/ir/lower/decl.zig | 76 ++++++++----- src/ir/lower/nominal.zig | 55 +++++---- 11 files changed, 264 insertions(+), 53 deletions(-) create mode 100644 examples/0546-packs-fn-alias-rich.sx create mode 100644 examples/0546-packs-fn-alias.sx create mode 100644 examples/expected/0546-packs-fn-alias.exit create mode 100644 examples/expected/0546-packs-fn-alias.stderr create mode 100644 examples/expected/0546-packs-fn-alias.stdout create mode 100644 issues/0121-pack-fn-alias-unresolved.md diff --git a/examples/0546-packs-fn-alias-rich.sx b/examples/0546-packs-fn-alias-rich.sx new file mode 100644 index 0000000..0f6c797 --- /dev/null +++ b/examples/0546-packs-fn-alias-rich.sx @@ -0,0 +1,6 @@ +// Companion of 0546: the authoring module for the fn-alias re-exports. +#import "modules/std.sx"; + +helper :: () -> s64 { 7 } +first_of :: (xs: []$T) -> T { xs[0] } +my_pack :: (..$args) -> s64 { args[0] + args[1] } diff --git a/examples/0546-packs-fn-alias.sx b/examples/0546-packs-fn-alias.sx new file mode 100644 index 0000000..cb1838d --- /dev/null +++ b/examples/0546-packs-fn-alias.sx @@ -0,0 +1,35 @@ +// Function aliases dispatch exactly like their target, across every fn +// kind: plain, runtime-generic ([]$T), and comptime-pack (..$args) — with +// bare, renamed, and namespace-member RHS. The alias is an ordinary own +// declaration, so it re-exports one flat-import level (companion file +// -rich.sx authors the fns aliased here and via the namespace). +// Regression (issue 0121): comptime-pack fn aliases (and ALL renamed fn +// aliases) used to fail "unresolved ''" — only same-name re-exports +// worked, through the name-keyed global registry. +#import "modules/std.sx"; +s :: #import "modules/std.sx"; +r :: #import "0546-packs-fn-alias-rich.sx"; + +pack_sum :: (..$args) -> s64 { + args[0] + args[1] +} +sum_alias :: pack_sum; // same-file pack alias (the 0121 repro) + +helper2 :: r.helper; // renamed plain, namespace RHS +head_of :: r.first_of; // renamed runtime-generic, namespace RHS +sum2 :: r.my_pack; // renamed pack, namespace RHS + +my_print :: s.print; // std's print — comptime pack + $fmt +my_format :: s.format; // value-returning sibling + +main :: () { + print("pack: {}\n", sum_alias(3, 4)); + print("plain: {}\n", helper2()); + arr := .[10, 20, 30]; + xs : []s64 = arr; + print("generic: {}\n", head_of(xs)); + print("ns-pack: {}\n", sum2(20, 22)); + my_print("std-print: {} {}\n", 1, "two"); + t := my_format("std-format {}", 42); + my_print("{}\n", t); +} diff --git a/examples/expected/0546-packs-fn-alias.exit b/examples/expected/0546-packs-fn-alias.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0546-packs-fn-alias.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0546-packs-fn-alias.stderr b/examples/expected/0546-packs-fn-alias.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0546-packs-fn-alias.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0546-packs-fn-alias.stdout b/examples/expected/0546-packs-fn-alias.stdout new file mode 100644 index 0000000..f5294e1 --- /dev/null +++ b/examples/expected/0546-packs-fn-alias.stdout @@ -0,0 +1,6 @@ +pack: 7 +plain: 7 +generic: 10 +ns-pack: 42 +std-print: 1 two +std-format 42 diff --git a/issues/0121-pack-fn-alias-unresolved.md b/issues/0121-pack-fn-alias-unresolved.md new file mode 100644 index 0000000..0745a7f --- /dev/null +++ b/issues/0121-pack-fn-alias-unresolved.md @@ -0,0 +1,112 @@ +# 0121 — aliasing a comptime-pack fn (`..$args`): "unresolved ''" + +> **RESOLVED** (2026-06-11, same session — Agra-directed). The symptom +> was broader than filed: RENAMED fn aliases failed for EVERY fn kind +> (plain `helper2 :: r.helper;` too) — the "plain fns verified working" +> claim below was a same-name confound (same-name re-exports resolve +> through the name-keyed global `fn_ast_map`, no alias mechanism +> involved; `my_pack :: r.my_pack;` already worked for packs too). +> Fix: fn aliases register at SCAN time — `scanDecls`' const-decl arm +> follows ident-/`ns.X`-RHS alias chains via `aliasedFnDecl` +> (nominal.zig; shares 0120's hop walk, now extracted as +> `followAliasChain`) and, when the chain terminates at a fn decl, +> registers the ALIAS name in `fn_ast_map` (absent-only — a real +> same-name fn keeps its slot). Every dispatch path reads that map +> (early pack/comptime/generic, plain lazy-lower, plan-side typing), +> so the alias dispatches exactly like the target with no per-path +> changes. Verified matrix: same-file pack alias (the repro), renamed +> plain / generic / pack through a facade, and `my_print :: s.print;` +> / `my_format :: s.format;` over std's real pack fns. Regression: +> `examples/0546-packs-fn-alias.sx` (+ `-rich.sx` companion). Gates: +> zig build test 0, suite 588/588. + +## Symptom + +A const alias of a function whose signature carries a comptime pack +(`..$args`) is not callable — every call through the alias fails with +`unresolved ''`. All three alias shapes fail identically: + +- same-file bare: `sum_alias :: pack_sum;` → `unresolved 'sum_alias'` +- bare RHS over a flat import: `my_print :: print;` (std's `print`) → + `unresolved 'my_print'` +- namespace RHS: `my_print :: s.print;` with `s :: #import + "modules/std.sx";` → `unresolved 'my_print'` + +Contrast (all verified working): plain concrete fns (`helper :: +r.helper;`), runtime-generic fns (`first_of :: r.first_of;` with +`(xs: []$T) -> T`), and — since 0120 — generic struct heads +(`List :: list.List;`). Only the comptime-pack shape misses. + +Expected: the alias dispatches exactly like the target — +`my_print("x {}\n", 1)` behaves as `print("x {}\n", 1)`. (If fn +aliasing of pack fns is NOT meant to be promised, the hypothesis is +wrong and the decl or the call should get a clean tailored +diagnostic instead — but the std.sx-as-pure-re-exports restructure +wants `print :: fmt.print;` to work, so support is the desirable +outcome. Confirm with Agra only if support turns out prohibitively +deep.) + +## Reproduction + +```sx +#import "modules/std.sx"; + +pack_sum :: (..$args) -> s64 { + args[0] + args[1] +} +sum_alias :: pack_sum; + +main :: () { + print("{}\n", sum_alias(3, 4)); // error: unresolved 'sum_alias' +} +``` + +Direct `pack_sum(3, 4)` works; only the aliased spelling fails. + +## Investigation prompt + +Comptime-pack calls (`..$args` — NOT slice-variadics `..xs: []T`) +dispatch EARLY in call lowering, keyed on the callee NAME against the +pack-fn template registry (the `fn_ast_map` entry whose FnDecl has a +comptime pack param; the early-dispatch gate lives in the call path — +`src/ir/lower/call.zig` / `src/ir/packs.zig`, grep for the pack-param +detection on the callee). An alias name has no `fn_ast_map` entry, so +the early pack dispatch misses; no later stage handles pack fns +(they cannot lower as ordinary declared functions — each call site +expands with its own bound pack), so the call falls through to the +generic `unresolved ''`. + +The fix likely: where the early pack dispatch resolves the callee +name, on a miss follow const-ALIAS decls to their target FnDecl and +dispatch with the TARGET's fd under the alias's call node. Issue +0120's fix added exactly this follow for generic STRUCT heads — +`aliasedStructTemplate` in `src/ir/lower/nominal.zig` +(`singleVisibleAuthor` + hop-by-hop `followToTemplate`, each hop +resolved from the ALIAS AUTHOR's source, `namespaceAliasVerdictFrom` +for `ns.X` RHS, depth-capped). Mirror that shape for fn targets — a +`followToFnDecl` sibling reusing `singleVisibleAuthor` (consider +extracting the shared walk) — and route the early pack dispatch +through it. Mind: own-wins / single-flat collision semantics must +match 0120's (≥2 flat alias authors → loud, no silent pick), and the +ufcs-alias map (`name :: ufcs target;`) is a DIFFERENT mechanism +(`ufcs_alias_map`) — don't conflate. + +Verification: + +1. The repro prints `7`, exit 0. +2. Matrix: same-file bare alias, bare RHS over a flat import + (`my_print :: print;`), namespace RHS (`my_print :: s.print;` / + `my_format :: s.format;` — formats AND returns a value), and a + consumer one flat hop from the aliasing facade. +3. Plain-fn and generic-fn aliases unchanged (examples 0211 family). +4. `bash tests/run_examples.sh` — 587/587 baseline must hold; pin the + repro as a regression example per CLAUDE.md. + +Context: BLOCKS the std.sx-as-pure-re-exports restructure — `print` / +`format` are the prelude's most-used names and are exactly this shape +(`($fmt: string, ..$args)`). Generic struct heads (`List`) were +unblocked by 0120; this is the remaining known gap. Still unprobed +for the restructure (next session, after this fix): protocol aliases +(`Allocator`, parameterized `Into`), `#builtin` decl aliases +(`size_of`, `out`, `string :: []u8`), `#foreign` decl aliases +(`memcpy`). diff --git a/readme.md b/readme.md index a3e79fb..bc42bab 100644 --- a/readme.md +++ b/readme.md @@ -486,9 +486,10 @@ carry does not chain through a second flat hop. **Re-exporting through alias declarations.** Since visibility never chains, a facade re-exports another module's members as its OWN declarations — ordinary aliases, which its direct flat importers then see bare. This works -for functions, plain types, and generic struct heads alike (the generic -alias binds the same template, so instantiation and methods resolve -through it): +for functions of every kind (plain, generic, comptime-pack like `print`), +plain types, and generic struct heads alike (the generic alias binds the +same template, so instantiation and methods resolve through it), renamed +or same-name: ```sx // facade.sx diff --git a/specs.md b/specs.md index 3972df8..3c6ab32 100644 --- a/specs.md +++ b/specs.md @@ -1270,6 +1270,23 @@ chain resolves with the visibility of the file that declares THAT hop, not the use site's. Not yet supported: a qualified head whose namespace member is itself an alias (`ns.BoxAlias(..)`). +### Function Aliases + +Functions alias the same way — bare or namespace-member RHS, renamed or +same-name — and the alias dispatches exactly like the target. This covers +every fn kind: plain, runtime-generic (`[]$T` / `$T: Type`), and +comptime-pack (`..$args`, e.g. `print` / `format`): + +```sx +s :: #import "modules/std.sx"; +my_print :: s.print; // comptime-pack fn through a namespace +helper2 :: r.helper; // renamed plain fn +my_print("x = {}\n", helper2()); +``` + +(For making an alias *dot-callable*, see `name :: ufcs target;` in the +UFCS section — that is a separate, explicit opt-in.) + ### Generic Functions (Monomorphization) Functions can be parameterized over types using `$T` syntax. The `$` prefix introduces a type parameter; subsequent uses of the name reference it. ```sx diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1c2c7f5..bc61391 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1688,6 +1688,7 @@ pub const Lowering = struct { pub const buildGenericStructTemplate = lower_nominal.buildGenericStructTemplate; pub const qualifiedStructTemplate = lower_nominal.qualifiedStructTemplate; pub const aliasedStructTemplate = lower_nominal.aliasedStructTemplate; + pub const aliasedFnDecl = lower_nominal.aliasedFnDecl; pub const qualifiedMemberMissing = lower_nominal.qualifiedMemberMissing; pub const bareVisibleStructDecl = lower_nominal.bareVisibleStructDecl; pub const bareVisibleStructTemplate = lower_nominal.bareVisibleStructTemplate; diff --git a/src/ir/lower/decl.zig b/src/ir/lower/decl.zig index 9018778..638bd53 100644 --- a/src/ir/lower/decl.zig +++ b/src/ir/lower/decl.zig @@ -211,8 +211,7 @@ pub fn checkRequiredEntryPoints(self: *Lowering) void { } if (self.diagnostics) |diags| { - diags.addFmt(.err, null, - "target is Android but no `#jni_main` Activity declared. " ++ + diags.addFmt(.err, null, "target is Android but no `#jni_main` Activity declared. " ++ "The OS launches a Java-side Activity that delegates lifecycle " ++ "callbacks into sx — declare one like:\n\n" ++ " Bundle :: #foreign #jni_class(\"android/os/Bundle\") {{ }}\n\n" ++ @@ -366,8 +365,7 @@ pub fn detectContextDecl(decls: []const *const Node) bool { for (decls) |decl| { const found = switch (decl.data) { .struct_decl => |sd| std.mem.eql(u8, sd.name, "Context"), - .const_decl => |cd| - std.mem.eql(u8, cd.name, "Context") and cd.value.data == .struct_decl, + .const_decl => |cd| std.mem.eql(u8, cd.name, "Context") and cd.value.data == .struct_decl, .namespace_decl => |ns| detectContextDecl(ns.decls), else => false, }; @@ -596,30 +594,48 @@ pub fn scanDecls(self: *Lowering, decls: []const *const Node) void { } } self.putTypeAlias(self.current_source_file, cd.name, target_ty); - } else if (cd.value.data == .identifier) { - // Identifier-RHS alias: MyAlias :: MyInt; WideAlias :: Wide. - // SOURCE-AWARE (E1.5). Resolve the RHS `B` AS SEEN FROM this - // alias's OWN source via `selectNominalLeaf` (E1's source- - // keyed nominal leaf), NEVER the global `type_alias_map` / - // global `findByName` (last-wins across modules). Only the - // `.resolved` outcome is written; `.pending` (B is itself a - // forward alias not resolved yet), `.undeclared`, and - // `.not_visible` (a same-name B authored only by a namespaced - // import) leave A UNWRITTEN so the source-aware - // `resolveForwardIdentifierAliases` fixpoint re-tries A once - // the local B registers. A GLOBAL selection here would bind A - // to a namespaced same-name B, and the per-source fixpoint - // guard (`aliasResolvedInSource`) would then SKIP A — leaving - // the wrong global TypeId and re-opening 0105 one layer down - // (R1, E1.5). Same unified `putTypeAlias` writer (no-drift). - const rhs = cd.value.data.identifier; + } else if (cd.value.data == .identifier or cd.value.data == .field_access) { + // FN alias (issue 0121): `print2 :: print;` / + // `my_print :: s.print;`. When the alias chain terminates + // at a fn decl, register the ALIAS name in `fn_ast_map` + // pointing at the target's decl — every dispatch path + // (early pack/comptime/generic, plain lazy-lower, + // plan-side return typing) reads that map, so the alias + // dispatches exactly like the target. Absent-only: a real + // same-name fn keeps its slot (same-name re-exports are + // a no-op — the target already owns the name). if (self.current_source_file orelse self.main_file) |from| { - switch (self.selectNominalLeaf(rhs.name, from, rhs.is_raw)) { - .resolved => |tid| self.putTypeAlias(self.current_source_file, cd.name, tid), - // `.ambiguous` (same-name RHS authored by ≥2 flat - // imports) leaves A unwritten like `.not_visible`; - // the loud diagnostic fires where A is USED. - .pending, .forward, .undeclared, .not_visible, .ambiguous => {}, + if (self.aliasedFnDecl(&decl.data.const_decl, from)) |target_fd| { + if (!self.program_index.fn_ast_map.contains(cd.name)) { + self.program_index.fn_ast_map.put(cd.name, target_fd) catch {}; + } + } + } + if (cd.value.data == .identifier) { + // Identifier-RHS alias: MyAlias :: MyInt; WideAlias :: Wide. + // SOURCE-AWARE (E1.5). Resolve the RHS `B` AS SEEN FROM this + // alias's OWN source via `selectNominalLeaf` (E1's source- + // keyed nominal leaf), NEVER the global `type_alias_map` / + // global `findByName` (last-wins across modules). Only the + // `.resolved` outcome is written; `.pending` (B is itself a + // forward alias not resolved yet), `.undeclared`, and + // `.not_visible` (a same-name B authored only by a namespaced + // import) leave A UNWRITTEN so the source-aware + // `resolveForwardIdentifierAliases` fixpoint re-tries A once + // the local B registers. A GLOBAL selection here would bind A + // to a namespaced same-name B, and the per-source fixpoint + // guard (`aliasResolvedInSource`) would then SKIP A — leaving + // the wrong global TypeId and re-opening 0105 one layer down + // (R1, E1.5). Same unified `putTypeAlias` writer (no-drift). + const rhs = cd.value.data.identifier; + if (self.current_source_file orelse self.main_file) |from| { + switch (self.selectNominalLeaf(rhs.name, from, rhs.is_raw)) { + .resolved => |tid| self.putTypeAlias(self.current_source_file, cd.name, tid), + // `.ambiguous` (same-name RHS authored by ≥2 flat + // imports) leaves A unwritten like `.not_visible`; + // the loud diagnostic fires where A is USED. + .pending, .forward, .undeclared, .not_visible, .ambiguous => {}, + } } } } @@ -686,7 +702,7 @@ pub fn scanDecls(self: *Lowering, decls: []const *const Node) void { // template via the namespace edge (mirrors the annotation // head site `resolveParameterizedWithBindings`), not the // bare last-wins `struct_template_map`. - const pt_alias: ?[]const u8 = if (pt_qualified) pt.name[0 .. std.mem.indexOfScalar(u8, pt.name, '.').?] else null; + const pt_alias: ?[]const u8 = if (pt_qualified) pt.name[0..std.mem.indexOfScalar(u8, pt.name, '.').?] else null; // Generic-struct alias base: route layout selection through the // single choke-point (CP-1); the builtin parameterised-type // path (Vector etc.) stays as the non-generic fall-through. @@ -1669,7 +1685,9 @@ pub fn selectNominalLeaf(self: *Lowering, name: []const u8, from: []const u8, ra else => self.namedRefTid(fa.raw, name), }; if (fa_tid) |t| { - if (found_tid) |f| { if (t != f) return .ambiguous; } else found_tid = t; + if (found_tid) |f| { + if (t != f) return .ambiguous; + } else found_tid = t; } else { flat_has_unregistered = true; } diff --git a/src/ir/lower/nominal.zig b/src/ir/lower/nominal.zig index eca0ade..99f148e 100644 --- a/src/ir/lower/nominal.zig +++ b/src/ir/lower/nominal.zig @@ -434,35 +434,48 @@ pub fn aliasedStructTemplate(self: *Lowering, name: []const u8, from: []const u8 } /// One alias hop: a generic-struct author terminates the chain with its -/// rebuilt source-pinned template; an alias author recurses on its RHS — -/// bare identifier from the author's own source, `ns.X` through the -/// author's namespace edge into the target module's own member. The depth -/// cap breaks alias cycles (`A :: B; B :: A;`). +/// rebuilt source-pinned template; an alias author recurses via +/// `followAliasChain`. fn followToTemplate(self: *Lowering, author: resolver_mod.RawAuthor, depth: u8) ?StructTemplate { + const terminal = followAliasChain(self, author, depth) orelse return null; + const sd = structDeclOfRaw(terminal.raw) orelse return null; + if (sd.type_params.len == 0) return null; + return self.buildGenericStructTemplate(sd, terminal.source); +} + +/// Walk a chain of const ALIAS decls to its terminal author. Each hop +/// resolves the RHS from the hop AUTHOR's own source — a bare identifier +/// via the visible-author walk, `ns.X` through the author's namespace edge +/// into the target module's own member. A non-alias author terminates the +/// chain (callers unwrap it by domain: `structDeclOfRaw` / `fnDeclOfRaw`). +/// The depth cap breaks alias cycles (`A :: B; B :: A;`). +pub fn followAliasChain(self: *Lowering, author: resolver_mod.RawAuthor, depth: u8) ?resolver_mod.RawAuthor { if (depth == 0) return null; - if (structDeclOfRaw(author.raw)) |sd| { - if (sd.type_params.len == 0) return null; - return self.buildGenericStructTemplate(sd, author.source); - } - const cd = constAliasOfRaw(author.raw) orelse return null; - switch (cd.value.data) { - .identifier => |id| { - const next = singleVisibleAuthor(self, id.name, author.source) orelse return null; - return followToTemplate(self, next, depth - 1); - }, - .field_access => |fa| { - if (fa.object.data != .identifier) return null; + const cd = constAliasOfRaw(author.raw) orelse return author; + const next: ?resolver_mod.RawAuthor = switch (cd.value.data) { + .identifier => |id| singleVisibleAuthor(self, id.name, author.source), + .field_access => |fa| blk: { + if (fa.object.data != .identifier) break :blk null; const target = switch (self.namespaceAliasVerdictFrom(fa.object.data.identifier.name, author.source)) { .target => |t| t, - .none, .ambiguous => return null, + .none, .ambiguous => break :blk null, }; var res = self.resolver(); const member_set = res.collectNamespaceAuthors(target, fa.field); - const member = member_set.own orelse return null; - return followToTemplate(self, member, depth - 1); + break :blk member_set.own; }, - else => return null, - } + else => null, + }; + return followAliasChain(self, next orelse return null, depth - 1); +} + +/// The fn decl a const ALIAS chain terminates at, or null when `cd` is not +/// an alias of a function. Entry for fn-alias registration (issue 0121): +/// `cd` itself seeds the chain (it IS the first alias hop), `from` is its +/// declaring source. +pub fn aliasedFnDecl(self: *Lowering, cd: *const ast.ConstDecl, from: []const u8) ?*const ast.FnDecl { + const terminal = followAliasChain(self, .{ .raw = .{ .const_decl = cd }, .source = from }, 9) orelse return null; + return Lowering.fnDeclOfRaw(terminal.raw); } /// The bare-VISIBLE single generic-struct author of `name` (its `StructDecl` +