diff --git a/examples/0722-modules-flat-same-name-own.sx b/examples/0722-modules-flat-same-name-own.sx new file mode 100644 index 0000000..911e594 --- /dev/null +++ b/examples/0722-modules-flat-same-name-own.sx @@ -0,0 +1,18 @@ +// fix-0102c (issue 0102): two flat FILE imports each author a same-name free +// function `greet`. The first-wins import merge keeps exactly one `greet` in +// the merged scope, but each module's OWN code must bind its OWN author when it +// calls `greet` bare. `from_a` (in a.sx) returns 1; `from_b` (in b.sx) returns +// 2 — per-source binding, resolved by identity, not first-wins. +#import "modules/std.sx"; +#import "0722-modules-flat-same-name-own/a.sx"; +#import "0722-modules-flat-same-name-own/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a binds a.greet", from_a() == 1); + report("from_b binds b.greet", from_b() == 2); + 0 +} diff --git a/examples/0722-modules-flat-same-name-own/a.sx b/examples/0722-modules-flat-same-name-own/a.sx new file mode 100644 index 0000000..07aa08f --- /dev/null +++ b/examples/0722-modules-flat-same-name-own/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `greet`. Its own `from_a` calls `greet` bare — under fix-0102c +// that binds a.sx's OWN author (own-author wins), even though b.sx also +// authors `greet` and the first-wins merge keeps only one in the merged scope. +greet :: () -> s64 { return 1; } +from_a :: () -> s64 { return greet(); } diff --git a/examples/0722-modules-flat-same-name-own/b.sx b/examples/0722-modules-flat-same-name-own/b.sx new file mode 100644 index 0000000..0c684d2 --- /dev/null +++ b/examples/0722-modules-flat-same-name-own/b.sx @@ -0,0 +1,4 @@ +// b.sx authors its OWN `greet`. `from_b`'s bare `greet` must bind b.sx's +// author (2), not the first-wins winner from a.sx. +greet :: () -> s64 { return 2; } +from_b :: () -> s64 { return greet(); } diff --git a/examples/0723-modules-flat-vs-namespaced.sx b/examples/0723-modules-flat-vs-namespaced.sx new file mode 100644 index 0000000..ac63e62 --- /dev/null +++ b/examples/0723-modules-flat-vs-namespaced.sx @@ -0,0 +1,17 @@ +// fix-0102c (issue 0102): one FLAT and one NAMESPACED author of `value`. The +// bare call `value()` binds the FLAT author (10); the namespaced author is +// reached only through `nm.value()` (20). A namespaced author must NOT make the +// bare call ambiguous — only flat authors collide. +#import "modules/std.sx"; +#import "0723-modules-flat-vs-namespaced/flat.sx"; +nm :: #import "0723-modules-flat-vs-namespaced/named.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("bare binds flat", value() == 10); + report("nm.value binds named", nm.value() == 20); + 0 +} diff --git a/examples/0723-modules-flat-vs-namespaced/flat.sx b/examples/0723-modules-flat-vs-namespaced/flat.sx new file mode 100644 index 0000000..7dc0cfd --- /dev/null +++ b/examples/0723-modules-flat-vs-namespaced/flat.sx @@ -0,0 +1,3 @@ +// Flat-imported author of `value`. A bare `value()` in the consumer binds THIS +// one — the only bare (flat) author of the name. +value :: () -> s64 { return 10; } diff --git a/examples/0723-modules-flat-vs-namespaced/named.sx b/examples/0723-modules-flat-vs-namespaced/named.sx new file mode 100644 index 0000000..132a422 --- /dev/null +++ b/examples/0723-modules-flat-vs-namespaced/named.sx @@ -0,0 +1,4 @@ +// Namespaced-imported author of `value`. Reachable only as `nm.value`; it never +// enters the flat merge, so it neither shadows the flat author nor makes the +// bare call ambiguous. +value :: () -> s64 { return 20; } diff --git a/examples/0724-modules-flat-same-name-ambiguous.sx b/examples/0724-modules-flat-same-name-ambiguous.sx new file mode 100644 index 0000000..7a623f0 --- /dev/null +++ b/examples/0724-modules-flat-same-name-ambiguous.sx @@ -0,0 +1,12 @@ +// fix-0102c (issue 0102): a genuinely-ambiguous bare call. `main` flat-imports +// two modules that each author `dup` and neither is `main`'s own — a bare +// `dup()` can't pick one, so the compiler rejects it with a loud diagnostic +// instead of silently first-wins-binding one. Qualify the call to disambiguate. +#import "modules/std.sx"; +#import "0724-modules-flat-same-name-ambiguous/a.sx"; +#import "0724-modules-flat-same-name-ambiguous/b.sx"; + +main :: () -> s32 { + print("{}\n", dup()); + 0 +} diff --git a/examples/0724-modules-flat-same-name-ambiguous/a.sx b/examples/0724-modules-flat-same-name-ambiguous/a.sx new file mode 100644 index 0000000..cb94ede --- /dev/null +++ b/examples/0724-modules-flat-same-name-ambiguous/a.sx @@ -0,0 +1,3 @@ +// One of two flat authors of `dup`. A consumer that flat-imports BOTH and calls +// `dup` bare cannot pick between them. +dup :: () -> s64 { return 1; } diff --git a/examples/0724-modules-flat-same-name-ambiguous/b.sx b/examples/0724-modules-flat-same-name-ambiguous/b.sx new file mode 100644 index 0000000..6bd821b --- /dev/null +++ b/examples/0724-modules-flat-same-name-ambiguous/b.sx @@ -0,0 +1,2 @@ +// The second flat author of `dup`. +dup :: () -> s64 { return 2; } diff --git a/examples/0725-modules-flat-dir-same-name.sx b/examples/0725-modules-flat-dir-same-name.sx new file mode 100644 index 0000000..910716e --- /dev/null +++ b/examples/0725-modules-flat-dir-same-name.sx @@ -0,0 +1,17 @@ +// fix-0102c (issue 0102): two flat DIRECTORY imports each author a same-name +// `tag`. A directory flat-import exposes the directory's authored functions, so +// `caller1`/`caller2` are visible here, and each binds its OWN directory's `tag` +// when it calls bare — per-source binding across directory imports (100 / 200). +#import "modules/std.sx"; +#import "0725-modules-flat-dir-same-name/d1"; +#import "0725-modules-flat-dir-same-name/d2"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("caller1 binds d1.tag", caller1() == 100); + report("caller2 binds d2.tag", caller2() == 200); + 0 +} diff --git a/examples/0725-modules-flat-dir-same-name/d1/one.sx b/examples/0725-modules-flat-dir-same-name/d1/one.sx new file mode 100644 index 0000000..47db691 --- /dev/null +++ b/examples/0725-modules-flat-dir-same-name/d1/one.sx @@ -0,0 +1,3 @@ +// d1's author of `tag`. `caller1` (also in d1) binds d1's own `tag` (100). +tag :: () -> s64 { return 100; } +caller1 :: () -> s64 { return tag(); } diff --git a/examples/0725-modules-flat-dir-same-name/d2/two.sx b/examples/0725-modules-flat-dir-same-name/d2/two.sx new file mode 100644 index 0000000..026037d --- /dev/null +++ b/examples/0725-modules-flat-dir-same-name/d2/two.sx @@ -0,0 +1,4 @@ +// d2's author of `tag`. `caller2` (also in d2) binds d2's own `tag` (200), +// even though d1's `tag` is the first-wins merge winner. +tag :: () -> s64 { return 200; } +caller2 :: () -> s64 { return tag(); } diff --git a/examples/0726-modules-flat-same-name-variadic.sx b/examples/0726-modules-flat-same-name-variadic.sx new file mode 100644 index 0000000..55f84ee --- /dev/null +++ b/examples/0726-modules-flat-same-name-variadic.sx @@ -0,0 +1,23 @@ +// fix-0102c F1 (issue 0102): two flat FILE imports each author same-name free +// functions whose VARIADIC SHAPE differs. `combine` is fixed-arity in a.sx +// (the first-wins winner) but variadic in b.sx (the shadow); `pick` is the +// reverse. Each module's bare call must pack arguments against ITS OWN +// author's signature — not the first-wins author's. Pre-fix the call path +// re-fetched the first-wins AST by name to drive variadic packing, so b.sx's +// variadic `combine` was packed as if fixed (and its fixed `pick` as if +// variadic) → wrong lowering. Regression for the F1 review finding. +#import "modules/std.sx"; +#import "0726-modules-flat-same-name-variadic/a.sx"; +#import "0726-modules-flat-same-name-variadic/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a combine fixed", from_a_combine() == 30); + report("from_b combine variadic", from_b_combine() == 10); + report("from_a pick variadic", from_a_pick() == 6); + report("from_b pick fixed", from_b_pick() == 5); + 0 +} diff --git a/examples/0726-modules-flat-same-name-variadic/a.sx b/examples/0726-modules-flat-same-name-variadic/a.sx new file mode 100644 index 0000000..55a1456 --- /dev/null +++ b/examples/0726-modules-flat-same-name-variadic/a.sx @@ -0,0 +1,11 @@ +// a.sx is the first-wins winner for both names. `combine` is FIXED arity; +// `pick` is VARIADIC. `from_a_*` call them bare — a authors the winner, so +// they resolve through the existing path and pack against a's own shapes. +combine :: (x: s64, y: s64) -> s64 { return x + y; } +pick :: (..xs: []s64) -> s64 { + result := 0; + for xs: (it) { result = result + it; } + result +} +from_a_combine :: () -> s64 { return combine(10, 20); } +from_a_pick :: () -> s64 { return pick(1, 2, 3); } diff --git a/examples/0726-modules-flat-same-name-variadic/b.sx b/examples/0726-modules-flat-same-name-variadic/b.sx new file mode 100644 index 0000000..654f21c --- /dev/null +++ b/examples/0726-modules-flat-same-name-variadic/b.sx @@ -0,0 +1,12 @@ +// b.sx is the SHADOW author for both names, with the OPPOSITE shapes: +// `combine` is VARIADIC, `pick` is FIXED. Each `from_b_*` bare call must pack +// against b's OWN author's signature (the F1 fix) — combine sums its variadic +// pack, pick subtracts its two fixed args. +combine :: (..xs: []s64) -> s64 { + result := 0; + for xs: (it) { result = result + it; } + result +} +pick :: (a: s64, b: s64) -> s64 { return b - a; } +from_b_combine :: () -> s64 { return combine(1, 2, 3, 4); } +from_b_pick :: () -> s64 { return pick(2, 7); } diff --git a/examples/0727-modules-user-ns-m0.sx b/examples/0727-modules-user-ns-m0.sx new file mode 100644 index 0000000..5740500 --- /dev/null +++ b/examples/0727-modules-user-ns-m0.sx @@ -0,0 +1,20 @@ +// fix-0102c (issue 0102): a user namespace alias literally named `__m0` +// coexists with flat same-name imports. fix-0102 resolves same-name authors by +// FnDecl IDENTITY — there are no synthetic `__m0`-style names to collide with — +// so a user namespace spelled `__m0` is just an ordinary namespace: `call_a` +// binds a.ping (1), `call_b` binds b.ping (2), and `__m0.ping` reaches m.ping (99). +#import "modules/std.sx"; +#import "0727-modules-user-ns-m0/a.sx"; +#import "0727-modules-user-ns-m0/b.sx"; +__m0 :: #import "0727-modules-user-ns-m0/m.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("call_a binds a.ping", call_a() == 1); + report("call_b binds b.ping", call_b() == 2); + report("__m0.ping binds m.ping", __m0.ping() == 99); + 0 +} diff --git a/examples/0727-modules-user-ns-m0/a.sx b/examples/0727-modules-user-ns-m0/a.sx new file mode 100644 index 0000000..f46b7f6 --- /dev/null +++ b/examples/0727-modules-user-ns-m0/a.sx @@ -0,0 +1,3 @@ +// Flat author of `ping`; `call_a` binds a.sx's own `ping` (1). +ping :: () -> s64 { return 1; } +call_a :: () -> s64 { return ping(); } diff --git a/examples/0727-modules-user-ns-m0/b.sx b/examples/0727-modules-user-ns-m0/b.sx new file mode 100644 index 0000000..af3fbfc --- /dev/null +++ b/examples/0727-modules-user-ns-m0/b.sx @@ -0,0 +1,3 @@ +// Second flat author of `ping`; `call_b` binds b.sx's own `ping` (2). +ping :: () -> s64 { return 2; } +call_b :: () -> s64 { return ping(); } diff --git a/examples/0727-modules-user-ns-m0/m.sx b/examples/0727-modules-user-ns-m0/m.sx new file mode 100644 index 0000000..db250e1 --- /dev/null +++ b/examples/0727-modules-user-ns-m0/m.sx @@ -0,0 +1,3 @@ +// Imported under a user namespace literally named `__m0`. Reached as +// `__m0.ping` (99); coexists with the flat `ping` collision. +ping :: () -> s64 { return 99; } diff --git a/examples/0728-modules-flat-same-name-paramtype.sx b/examples/0728-modules-flat-same-name-paramtype.sx new file mode 100644 index 0000000..2810bb9 --- /dev/null +++ b/examples/0728-modules-flat-same-name-paramtype.sx @@ -0,0 +1,22 @@ +// fix-0102c F2 (issue 0102): two flat FILE imports each author a same-name free +// function `apply` with a DIFFERENT parameter TYPE — a.sx takes a value +// (`x: s64`), b.sx takes a pointer (`x: *s64`). The first-wins import merge +// keeps a.sx's value-typed `apply`, but each module's bare call must type its +// arguments against ITS OWN author. b.sx's `from_b` passes a local `v` to its +// pointer-param `apply` via implicit address-of; before the fix the arg was +// typed against the first-wins (value) winner, lowered as a value, then the +// resolved pointer-param author was called with that value bit-cast to a +// pointer — a segfault. Regression: per-source parameter target typing. +#import "modules/std.sx"; +#import "0728-modules-flat-same-name-paramtype/a.sx"; +#import "0728-modules-flat-same-name-paramtype/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a binds a.apply (value param)", from_a() == 11); + report("from_b binds b.apply (pointer param)", from_b() == 42); + 0 +} diff --git a/examples/0728-modules-flat-same-name-paramtype/a.sx b/examples/0728-modules-flat-same-name-paramtype/a.sx new file mode 100644 index 0000000..4b15eb4 --- /dev/null +++ b/examples/0728-modules-flat-same-name-paramtype/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `apply` taking a VALUE. It is imported first, so it is the +// first-wins merge winner. `from_a` calls `apply` bare on a value local — its +// own author wins (own == winner → existing path, byte-for-byte unchanged). +apply :: (x: s64) -> s64 { return x + 1; } +from_a :: () -> s64 { v : s64 = 10; return apply(v); } diff --git a/examples/0728-modules-flat-same-name-paramtype/b.sx b/examples/0728-modules-flat-same-name-paramtype/b.sx new file mode 100644 index 0000000..ebaa0ec --- /dev/null +++ b/examples/0728-modules-flat-same-name-paramtype/b.sx @@ -0,0 +1,7 @@ +// b.sx authors its OWN `apply` taking a POINTER. `from_b` passes a value local +// `v` bare; the pointer param must drive implicit address-of so the callee +// mutates `v` in place (×2 → 42). Before the fix, `v` was typed against a.sx's +// value-param winner, lowered as a value, then the resolved pointer-param +// author was called with that value forced to a pointer (segfault). +apply :: (x: *s64) { x.* = x.* * 2; } +from_b :: () -> s64 { v : s64 = 21; apply(v); return v; } diff --git a/examples/0729-modules-flat-same-name-foreign.sx b/examples/0729-modules-flat-same-name-foreign.sx new file mode 100644 index 0000000..1fcb5b1 --- /dev/null +++ b/examples/0729-modules-flat-same-name-foreign.sx @@ -0,0 +1,14 @@ +// fix-0102c (issue 0102) F3 regression: two flat FILE imports each `#foreign` +// the SAME libc symbol under the SAME sx name `absval`. The bare-call resolver +// must NOT count `#foreign` (non-plain) authors when deciding ambiguity — it +// filters them out, returns "no rerouting", and the existing first-wins foreign +// dispatch binds the call. A same-name foreign collision therefore compiles and +// runs (master behavior), it does NOT error as ambiguous. +#import "modules/std.sx"; +#import "0729-modules-flat-same-name-foreign/a.sx"; +#import "0729-modules-flat-same-name-foreign/b.sx"; + +main :: () -> s32 { + print("absval = {}\n", absval(-7)); + 0 +} diff --git a/examples/0729-modules-flat-same-name-foreign/a.sx b/examples/0729-modules-flat-same-name-foreign/a.sx new file mode 100644 index 0000000..13b27f3 --- /dev/null +++ b/examples/0729-modules-flat-same-name-foreign/a.sx @@ -0,0 +1,5 @@ +// One of two flat authors of `absval`, a `#foreign` libc binding. A consumer +// flat-importing BOTH must NOT see this as an ambiguous bare-call collision — +// foreign authors are never rerouted by the bare-call resolver, so the call +// falls to the existing first-wins foreign dispatch. +absval :: (n: s32) -> s32 #foreign libc "abs"; diff --git a/examples/0729-modules-flat-same-name-foreign/b.sx b/examples/0729-modules-flat-same-name-foreign/b.sx new file mode 100644 index 0000000..527122e --- /dev/null +++ b/examples/0729-modules-flat-same-name-foreign/b.sx @@ -0,0 +1,2 @@ +// The second flat author of `absval` — the identical `#foreign` libc binding. +absval :: (n: s32) -> s32 #foreign libc "abs"; diff --git a/examples/0730-modules-flat-same-name-default-arg.sx b/examples/0730-modules-flat-same-name-default-arg.sx new file mode 100644 index 0000000..98da771 --- /dev/null +++ b/examples/0730-modules-flat-same-name-default-arg.sx @@ -0,0 +1,20 @@ +// fix-0102d site 1 (issue 0102): two flat FILE imports each author a same-name +// free function `cfg` with a DIFFERENT default value for its trailing param — +// a.sx defaults to 10, b.sx to 20. Each module calls `cfg()` bare with the arg +// OMITTED. The omitted trailing arg must be filled from the RESOLVED author's +// default (own-author wins), not the first-wins winner's. Before the fix, +// `from_b`'s `cfg()` expanded to the winner a.sx's default (10) and returned 10. +// Regression: per-source default-argument expansion. +#import "modules/std.sx"; +#import "0730-modules-flat-same-name-default-arg/a.sx"; +#import "0730-modules-flat-same-name-default-arg/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a binds a.cfg default (10)", from_a() == 10); + report("from_b binds b.cfg default (20)", from_b() == 20); + 0 +} diff --git a/examples/0730-modules-flat-same-name-default-arg/a.sx b/examples/0730-modules-flat-same-name-default-arg/a.sx new file mode 100644 index 0000000..7d2fa02 --- /dev/null +++ b/examples/0730-modules-flat-same-name-default-arg/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `cfg` defaulting to 10. Imported first, so it is the first-wins +// merge winner. `from_a` calls `cfg()` with the arg omitted — own == winner → +// existing default-expansion path, byte-for-byte unchanged. +cfg :: (n: s64 = 10) -> s64 { return n; } +from_a :: () -> s64 { return cfg(); } diff --git a/examples/0730-modules-flat-same-name-default-arg/b.sx b/examples/0730-modules-flat-same-name-default-arg/b.sx new file mode 100644 index 0000000..909827c --- /dev/null +++ b/examples/0730-modules-flat-same-name-default-arg/b.sx @@ -0,0 +1,5 @@ +// b.sx authors its OWN `cfg` defaulting to 20. `from_b`'s `cfg()` omits the +// arg; the omitted trailing default must come from b.sx's author (20), not the +// first-wins winner from a.sx (10). +cfg :: (n: s64 = 20) -> s64 { return n; } +from_b :: () -> s64 { return cfg(); } diff --git a/examples/0731-modules-flat-same-name-closure.sx b/examples/0731-modules-flat-same-name-closure.sx new file mode 100644 index 0000000..53c11a9 --- /dev/null +++ b/examples/0731-modules-flat-same-name-closure.sx @@ -0,0 +1,22 @@ +// fix-0102d site 2 (issue 0102): two flat FILE imports each author a same-name +// free function `pick` (a.sx returns 1, b.sx returns 2). Each module takes +// `pick` as a function VALUE — both as `closure(pick)` and as a bare-name +// fn-pointer binding (`g : () -> s64 = pick`). The captured FuncId must be the +// RESOLVED author's (own-author wins), not the first-wins winner's. Before the +// fix, b.sx's `closure(pick)` / `pick`-as-value both captured a.sx's winner +// (1). Regression: per-source function-value conversion (closure + func_ref). +#import "modules/std.sx"; +#import "0731-modules-flat-same-name-closure/a.sx"; +#import "0731-modules-flat-same-name-closure/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a closure binds a.pick (1)", from_a_closure() == 1); + report("from_b closure binds b.pick (2)", from_b_closure() == 2); + report("from_a fn-value binds a.pick (1)", from_a_value() == 1); + report("from_b fn-value binds b.pick (2)", from_b_value() == 2); + 0 +} diff --git a/examples/0731-modules-flat-same-name-closure/a.sx b/examples/0731-modules-flat-same-name-closure/a.sx new file mode 100644 index 0000000..5420616 --- /dev/null +++ b/examples/0731-modules-flat-same-name-closure/a.sx @@ -0,0 +1,6 @@ +// a.sx authors `pick` returning 1. Imported first → first-wins winner. +// `from_a_closure` / `from_a_value` take a.sx's own author (own == winner → +// existing path, byte-for-byte unchanged). +pick :: () -> s64 { return 1; } +from_a_closure :: () -> s64 { f := closure(pick); return f(); } +from_a_value :: () -> s64 { g : () -> s64 = pick; return g(); } diff --git a/examples/0731-modules-flat-same-name-closure/b.sx b/examples/0731-modules-flat-same-name-closure/b.sx new file mode 100644 index 0000000..b70cd11 --- /dev/null +++ b/examples/0731-modules-flat-same-name-closure/b.sx @@ -0,0 +1,6 @@ +// b.sx authors its OWN `pick` returning 2. Taking `pick` as a value — +// `closure(pick)` or `g : () -> s64 = pick` — must capture b.sx's author (2), +// not the first-wins winner from a.sx (1). +pick :: () -> s64 { return 2; } +from_b_closure :: () -> s64 { f := closure(pick); return f(); } +from_b_value :: () -> s64 { g : () -> s64 = pick; return g(); } diff --git a/examples/0732-modules-flat-same-name-ufcs.sx b/examples/0732-modules-flat-same-name-ufcs.sx new file mode 100644 index 0000000..e817480 --- /dev/null +++ b/examples/0732-modules-flat-same-name-ufcs.sx @@ -0,0 +1,19 @@ +// fix-0102d site 3 (issue 0102): two flat FILE imports each author a same-name +// free function `bump` (a.sx adds 1, b.sx adds 100). Each module dispatches it +// via free-function UFCS — `v.bump()` lowers to `bump(v)`. The dispatched +// author must be the RESOLVED one for the receiver's source (own-author wins), +// not the first-wins winner. Before the fix, b.sx's `v.bump()` dispatched +// a.sx's winner (+1 → 11). Regression: per-source free-function UFCS dispatch. +#import "modules/std.sx"; +#import "0732-modules-flat-same-name-ufcs/a.sx"; +#import "0732-modules-flat-same-name-ufcs/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("from_a v.bump() binds a.bump (+1)", from_a_ufcs() == 11); + report("from_b v.bump() binds b.bump (+100)", from_b_ufcs() == 110); + 0 +} diff --git a/examples/0732-modules-flat-same-name-ufcs/a.sx b/examples/0732-modules-flat-same-name-ufcs/a.sx new file mode 100644 index 0000000..b96726c --- /dev/null +++ b/examples/0732-modules-flat-same-name-ufcs/a.sx @@ -0,0 +1,5 @@ +// a.sx authors `bump` adding 1. Imported first → first-wins winner. `from_a`'s +// `v.bump()` resolves a.sx's own author (own == winner → existing UFCS path, +// byte-for-byte unchanged). +bump :: (x: s64) -> s64 { return x + 1; } +from_a_ufcs :: () -> s64 { v : s64 = 10; return v.bump(); } diff --git a/examples/0732-modules-flat-same-name-ufcs/b.sx b/examples/0732-modules-flat-same-name-ufcs/b.sx new file mode 100644 index 0000000..f7e928b --- /dev/null +++ b/examples/0732-modules-flat-same-name-ufcs/b.sx @@ -0,0 +1,4 @@ +// b.sx authors its OWN `bump` adding 100. `from_b`'s `v.bump()` must dispatch +// b.sx's author (+100 → 110), not the first-wins winner from a.sx (+1). +bump :: (x: s64) -> s64 { return x + 100; } +from_b_ufcs :: () -> s64 { v : s64 = 10; return v.bump(); } diff --git a/examples/0733-modules-flat-same-name-comptime-run.sx b/examples/0733-modules-flat-same-name-comptime-run.sx new file mode 100644 index 0000000..9b7d24c --- /dev/null +++ b/examples/0733-modules-flat-same-name-comptime-run.sx @@ -0,0 +1,21 @@ +// fix-0102d site 4 (issue 0102): two flat FILE imports each author a same-name +// free function `compute` (a.sx returns 7, b.sx returns 70) and each evaluates +// it at comptime via `NAME :: #run compute();`. The #run body must resolve the +// bare callee from ITS OWN module's source context (own-author wins), so a.sx's +// const is 7 and b.sx's is 70. Before the fix, the #run body lowered with the +// main file's source perspective, where `compute` is authored by two flat +// imports and neither is main's own — so it was reported AMBIGUOUS and the +// build failed. Regression: per-source comptime #run callee resolution. +#import "modules/std.sx"; +#import "0733-modules-flat-same-name-comptime-run/a.sx"; +#import "0733-modules-flat-same-name-comptime-run/b.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> s32 { + report("a.sx #run binds a.compute (7)", get_a() == 7); + report("b.sx #run binds b.compute (70)", get_b() == 70); + 0 +} diff --git a/examples/0733-modules-flat-same-name-comptime-run/a.sx b/examples/0733-modules-flat-same-name-comptime-run/a.sx new file mode 100644 index 0000000..a2e9489 --- /dev/null +++ b/examples/0733-modules-flat-same-name-comptime-run/a.sx @@ -0,0 +1,6 @@ +// a.sx authors `compute` returning 7 and evaluates it at comptime. Imported +// first → first-wins winner; own == winner, but the #run must still lower in +// a.sx's source context so the bare `compute` resolves at all (not ambiguous). +compute :: () -> s64 { return 7; } +A_VAL :: #run compute(); +get_a :: () -> s64 { return A_VAL; } diff --git a/examples/0733-modules-flat-same-name-comptime-run/b.sx b/examples/0733-modules-flat-same-name-comptime-run/b.sx new file mode 100644 index 0000000..c64d1c6 --- /dev/null +++ b/examples/0733-modules-flat-same-name-comptime-run/b.sx @@ -0,0 +1,6 @@ +// b.sx authors its OWN `compute` returning 70. Its `#run compute()` must bind +// b.sx's author (70) — own-author wins in b.sx's source context — not the +// first-wins winner from a.sx (7). +compute :: () -> s64 { return 70; } +B_VAL :: #run compute(); +get_b :: () -> s64 { return B_VAL; } diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous.sx new file mode 100644 index 0000000..ca2bb7c --- /dev/null +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous.sx @@ -0,0 +1,16 @@ +// fix-0102d site 3 ambiguity (issue 0102): two flat FILE imports each author a +// same-name free function `dup`, and the MAIN file (which authors neither) +// dispatches it via free-function UFCS `v.dup()`. With two distinct flat +// authors reachable and no own-author to prefer, the call is ambiguous — the +// UFCS dispatch site must emit the loud "qualify the call" diagnostic rather +// than silently binding the first-wins winner. Mirrors 0724 (the bare-call +// ambiguity) one site over. +#import "modules/std.sx"; +#import "0734-modules-flat-same-name-ufcs-ambiguous/a.sx"; +#import "0734-modules-flat-same-name-ufcs-ambiguous/b.sx"; + +main :: () -> s32 { + v : s64 = 10; + print("{}\n", v.dup()); + 0 +} diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx new file mode 100644 index 0000000..1381062 --- /dev/null +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous/a.sx @@ -0,0 +1,2 @@ +// a.sx authors `dup` (+1). One of two distinct flat authors of `dup`. +dup :: (x: s64) -> s64 { return x + 1; } diff --git a/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx b/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx new file mode 100644 index 0000000..eb8120b --- /dev/null +++ b/examples/0734-modules-flat-same-name-ufcs-ambiguous/b.sx @@ -0,0 +1,3 @@ +// b.sx authors its OWN `dup` (+2) — the second distinct flat author. Main +// imports both and authors neither, so `v.dup()` from main is ambiguous. +dup :: (x: s64) -> s64 { return x + 2; } diff --git a/examples/0735-modules-flat-same-name-fn-value-winner.sx b/examples/0735-modules-flat-same-name-fn-value-winner.sx new file mode 100644 index 0000000..639298e --- /dev/null +++ b/examples/0735-modules-flat-same-name-fn-value-winner.sx @@ -0,0 +1,17 @@ +// fix-0102d site 2 / attempt-2 (issue 0102): the first-wins winner's body is +// independently BROKEN (references an undefined symbol) and is never used. A +// shadow author from a later flat import takes its OWN `pick` as a function +// VALUE (`g : () -> s64 = pick`). The value must bind the shadow (own-author +// wins) and the broken winner must NOT be lowered — a rerouted fn value never +// uses the winner. Before the fix the fn-value site eagerly lazily-lowered the +// name-keyed winner BEFORE the resolver rerouted, surfacing the winner's +// `unresolved 'missing_from_a'` for a function the value never touches. +// Regression: per-source function-value conversion must not pre-lower the winner. +#import "modules/std.sx"; +#import "0735-modules-flat-same-name-fn-value-winner/a.sx"; +#import "0735-modules-flat-same-name-fn-value-winner/b.sx"; + +main :: () -> s32 { + print("from_b_value = {}\n", from_b_value()); + 0 +} diff --git a/examples/0735-modules-flat-same-name-fn-value-winner/a.sx b/examples/0735-modules-flat-same-name-fn-value-winner/a.sx new file mode 100644 index 0000000..c279dbf --- /dev/null +++ b/examples/0735-modules-flat-same-name-fn-value-winner/a.sx @@ -0,0 +1,4 @@ +// a.sx authors `pick` (imported first → the first-wins name-keyed winner) but +// its body references an undefined symbol, so lowering a.pick AT ALL is an +// error. Nothing uses a.pick — taking b.pick as a value must not pre-lower it. +pick :: () -> s64 { return missing_from_a(); } diff --git a/examples/0735-modules-flat-same-name-fn-value-winner/b.sx b/examples/0735-modules-flat-same-name-fn-value-winner/b.sx new file mode 100644 index 0000000..3ddcb3e --- /dev/null +++ b/examples/0735-modules-flat-same-name-fn-value-winner/b.sx @@ -0,0 +1,7 @@ +// b.sx authors its OWN `pick` (returns 2) and takes it as a function VALUE. The +// value binds b.pick (own-author wins), never the broken winner from a.sx. +pick :: () -> s64 { return 2; } +from_b_value :: () -> s64 { + g : () -> s64 = pick; + return g(); +} diff --git a/examples/expected/0722-modules-flat-same-name-own.exit b/examples/expected/0722-modules-flat-same-name-own.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0722-modules-flat-same-name-own.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0722-modules-flat-same-name-own.stderr b/examples/expected/0722-modules-flat-same-name-own.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0722-modules-flat-same-name-own.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0722-modules-flat-same-name-own.stdout b/examples/expected/0722-modules-flat-same-name-own.stdout new file mode 100644 index 0000000..d9c2011 --- /dev/null +++ b/examples/expected/0722-modules-flat-same-name-own.stdout @@ -0,0 +1,2 @@ +from_a binds a.greet: ok +from_b binds b.greet: ok diff --git a/examples/expected/0723-modules-flat-vs-namespaced.exit b/examples/expected/0723-modules-flat-vs-namespaced.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0723-modules-flat-vs-namespaced.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0723-modules-flat-vs-namespaced.stderr b/examples/expected/0723-modules-flat-vs-namespaced.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0723-modules-flat-vs-namespaced.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0723-modules-flat-vs-namespaced.stdout b/examples/expected/0723-modules-flat-vs-namespaced.stdout new file mode 100644 index 0000000..9e6841a --- /dev/null +++ b/examples/expected/0723-modules-flat-vs-namespaced.stdout @@ -0,0 +1,2 @@ +bare binds flat: ok +nm.value binds named: ok diff --git a/examples/expected/0724-modules-flat-same-name-ambiguous.exit b/examples/expected/0724-modules-flat-same-name-ambiguous.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0724-modules-flat-same-name-ambiguous.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0724-modules-flat-same-name-ambiguous.stderr b/examples/expected/0724-modules-flat-same-name-ambiguous.stderr new file mode 100644 index 0000000..46bf580 --- /dev/null +++ b/examples/expected/0724-modules-flat-same-name-ambiguous.stderr @@ -0,0 +1,5 @@ +error: 'dup' is ambiguous; declared by multiple imported modules — qualify the call + --> examples/0724-modules-flat-same-name-ambiguous.sx:10:19 + | +10 | print("{}\n", dup()); + | ^^^ diff --git a/examples/expected/0724-modules-flat-same-name-ambiguous.stdout b/examples/expected/0724-modules-flat-same-name-ambiguous.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0724-modules-flat-same-name-ambiguous.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/0725-modules-flat-dir-same-name.exit b/examples/expected/0725-modules-flat-dir-same-name.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0725-modules-flat-dir-same-name.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0725-modules-flat-dir-same-name.stderr b/examples/expected/0725-modules-flat-dir-same-name.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0725-modules-flat-dir-same-name.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0725-modules-flat-dir-same-name.stdout b/examples/expected/0725-modules-flat-dir-same-name.stdout new file mode 100644 index 0000000..89953af --- /dev/null +++ b/examples/expected/0725-modules-flat-dir-same-name.stdout @@ -0,0 +1,2 @@ +caller1 binds d1.tag: ok +caller2 binds d2.tag: ok diff --git a/examples/expected/0726-modules-flat-same-name-variadic.exit b/examples/expected/0726-modules-flat-same-name-variadic.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0726-modules-flat-same-name-variadic.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0726-modules-flat-same-name-variadic.stderr b/examples/expected/0726-modules-flat-same-name-variadic.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0726-modules-flat-same-name-variadic.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0726-modules-flat-same-name-variadic.stdout b/examples/expected/0726-modules-flat-same-name-variadic.stdout new file mode 100644 index 0000000..2942265 --- /dev/null +++ b/examples/expected/0726-modules-flat-same-name-variadic.stdout @@ -0,0 +1,4 @@ +from_a combine fixed: ok +from_b combine variadic: ok +from_a pick variadic: ok +from_b pick fixed: ok diff --git a/examples/expected/0727-modules-user-ns-m0.exit b/examples/expected/0727-modules-user-ns-m0.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0727-modules-user-ns-m0.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0727-modules-user-ns-m0.stderr b/examples/expected/0727-modules-user-ns-m0.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0727-modules-user-ns-m0.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0727-modules-user-ns-m0.stdout b/examples/expected/0727-modules-user-ns-m0.stdout new file mode 100644 index 0000000..65286e5 --- /dev/null +++ b/examples/expected/0727-modules-user-ns-m0.stdout @@ -0,0 +1,3 @@ +call_a binds a.ping: ok +call_b binds b.ping: ok +__m0.ping binds m.ping: ok diff --git a/examples/expected/0728-modules-flat-same-name-paramtype.exit b/examples/expected/0728-modules-flat-same-name-paramtype.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0728-modules-flat-same-name-paramtype.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0728-modules-flat-same-name-paramtype.stderr b/examples/expected/0728-modules-flat-same-name-paramtype.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0728-modules-flat-same-name-paramtype.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0728-modules-flat-same-name-paramtype.stdout b/examples/expected/0728-modules-flat-same-name-paramtype.stdout new file mode 100644 index 0000000..7b8008c --- /dev/null +++ b/examples/expected/0728-modules-flat-same-name-paramtype.stdout @@ -0,0 +1,2 @@ +from_a binds a.apply (value param): ok +from_b binds b.apply (pointer param): ok diff --git a/examples/expected/0729-modules-flat-same-name-foreign.exit b/examples/expected/0729-modules-flat-same-name-foreign.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0729-modules-flat-same-name-foreign.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0729-modules-flat-same-name-foreign.stderr b/examples/expected/0729-modules-flat-same-name-foreign.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0729-modules-flat-same-name-foreign.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0729-modules-flat-same-name-foreign.stdout b/examples/expected/0729-modules-flat-same-name-foreign.stdout new file mode 100644 index 0000000..bb4defe --- /dev/null +++ b/examples/expected/0729-modules-flat-same-name-foreign.stdout @@ -0,0 +1 @@ +absval = 7 diff --git a/examples/expected/0730-modules-flat-same-name-default-arg.exit b/examples/expected/0730-modules-flat-same-name-default-arg.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0730-modules-flat-same-name-default-arg.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0730-modules-flat-same-name-default-arg.stderr b/examples/expected/0730-modules-flat-same-name-default-arg.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0730-modules-flat-same-name-default-arg.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0730-modules-flat-same-name-default-arg.stdout b/examples/expected/0730-modules-flat-same-name-default-arg.stdout new file mode 100644 index 0000000..b8b77f3 --- /dev/null +++ b/examples/expected/0730-modules-flat-same-name-default-arg.stdout @@ -0,0 +1,2 @@ +from_a binds a.cfg default (10): ok +from_b binds b.cfg default (20): ok diff --git a/examples/expected/0731-modules-flat-same-name-closure.exit b/examples/expected/0731-modules-flat-same-name-closure.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0731-modules-flat-same-name-closure.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0731-modules-flat-same-name-closure.stderr b/examples/expected/0731-modules-flat-same-name-closure.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0731-modules-flat-same-name-closure.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0731-modules-flat-same-name-closure.stdout b/examples/expected/0731-modules-flat-same-name-closure.stdout new file mode 100644 index 0000000..3284dbe --- /dev/null +++ b/examples/expected/0731-modules-flat-same-name-closure.stdout @@ -0,0 +1,4 @@ +from_a closure binds a.pick (1): ok +from_b closure binds b.pick (2): ok +from_a fn-value binds a.pick (1): ok +from_b fn-value binds b.pick (2): ok diff --git a/examples/expected/0732-modules-flat-same-name-ufcs.exit b/examples/expected/0732-modules-flat-same-name-ufcs.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0732-modules-flat-same-name-ufcs.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0732-modules-flat-same-name-ufcs.stderr b/examples/expected/0732-modules-flat-same-name-ufcs.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0732-modules-flat-same-name-ufcs.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0732-modules-flat-same-name-ufcs.stdout b/examples/expected/0732-modules-flat-same-name-ufcs.stdout new file mode 100644 index 0000000..5fed027 --- /dev/null +++ b/examples/expected/0732-modules-flat-same-name-ufcs.stdout @@ -0,0 +1,2 @@ +from_a v.bump() binds a.bump (+1): ok +from_b v.bump() binds b.bump (+100): ok diff --git a/examples/expected/0733-modules-flat-same-name-comptime-run.exit b/examples/expected/0733-modules-flat-same-name-comptime-run.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0733-modules-flat-same-name-comptime-run.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0733-modules-flat-same-name-comptime-run.stderr b/examples/expected/0733-modules-flat-same-name-comptime-run.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0733-modules-flat-same-name-comptime-run.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0733-modules-flat-same-name-comptime-run.stdout b/examples/expected/0733-modules-flat-same-name-comptime-run.stdout new file mode 100644 index 0000000..01a9d4d --- /dev/null +++ b/examples/expected/0733-modules-flat-same-name-comptime-run.stdout @@ -0,0 +1,2 @@ +a.sx #run binds a.compute (7): ok +b.sx #run binds b.compute (70): ok diff --git a/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr new file mode 100644 index 0000000..c2e2c2c --- /dev/null +++ b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stderr @@ -0,0 +1,5 @@ +error: 'dup' is ambiguous; declared by multiple imported modules — qualify the call + --> examples/0734-modules-flat-same-name-ufcs-ambiguous.sx:14:19 + | +14 | print("{}\n", v.dup()); + | ^^^^^ diff --git a/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0734-modules-flat-same-name-ufcs-ambiguous.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/0735-modules-flat-same-name-fn-value-winner.exit b/examples/expected/0735-modules-flat-same-name-fn-value-winner.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0735-modules-flat-same-name-fn-value-winner.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout new file mode 100644 index 0000000..09f6872 --- /dev/null +++ b/examples/expected/0735-modules-flat-same-name-fn-value-winner.stdout @@ -0,0 +1 @@ +from_b_value = 2 diff --git a/issues/0102-flat-import-same-signature-collision.md b/issues/0102-flat-import-same-signature-collision.md new file mode 100644 index 0000000..477057d --- /dev/null +++ b/issues/0102-flat-import-same-signature-collision.md @@ -0,0 +1,102 @@ +# 0102 — flat-import same-name function collision (per-source binding) + +**RESOLVED.** Two **flat** imports (bare `#import "a.sx"` / a flat directory +import, NOT a namespaced `ns :: #import`) that each author a top-level free +function with the **same short name** collided in IR lowering. The flat/ +directory merge keeps exactly **one** author per name in the merged decl list +(first-wins), and every bare-name consumer site — call dispatch, default-arg +expansion, function-value capture, free-function UFCS, comptime `#run` — read +that one **name-keyed** winner. So when module `b.sx` authored its own `greet` +but `a.sx` was imported first, `b.sx`'s own bare `greet()` silently bound +`a.sx`'s author. Unlike issue 0100 (which crashed on a param-count assert when +the AST/FuncId split across modules), this miscompiled **silently**: the wrong +same-name author ran, with no diagnostic. + +The defect had two faces, both rooted in name-keyed identity across a flat +collision: + +1. **Lowering** keyed function bodies by short name (`fn_ast_map` / + `resolveFuncByName` are first-wins), so a shadowed author never got its own + FuncId or body — there was nothing to bind even if a consumer wanted the + per-source author. +2. **Resolution** at every bare-name consumer site re-looked-up the winner by + name, so even once shadow authors had distinct FuncIds, the consumer sites + kept binding the first-wins winner. + +## Fix — four sub-steps (`src/imports.zig`, `src/ir/lower.zig`) + +- **0102a — retain dup authors + identity indexes.** The flat/directory merge + keeps first-wins in the merged scope (unchanged), but now *also* retains + every dropped same-name author in `program_index.module_fns` + (`path → name → *FnDecl`) plus a `flat_import_graph` (`file → flat-import + edges`). Resolution is untouched at this step — the indexes just make the + shadowed authors addressable. + +- **0102b — identity-addressable function lowering.** `fn_decl_fids` + (`*const ast.FnDecl → FuncId`) lets a body be declared + lowered against a + **specific** `*FnDecl` (`lowerFunctionBodyInto` / `bareAuthorFuncId`) instead + of a name. A shadow author gets a fresh same-name FuncId in its own module's + visibility context; the winner keeps the name-keyed slot. `scanDecls` keys + `fn_decl_fids` by the stable `module_fns` `*FnDecl`. + +- **0102c — THE resolver + call path + param typing.** + `resolveBareCallee(name, caller_file) -> .func(ResolvedAuthor) | .ambiguous | + .none` (`src/ir/lower.zig`). It returns `.none` whenever the outcome would + equal first-wins (single author, or own-author == winner), so every + single-author / local / parameter / std / qualified / foreign / generic / + builtin name resolves byte-for-byte as before. Only a genuine flat collision + reroutes: own-author wins; else the caller's flat-reachable authors — `≥2` + distinct → `.ambiguous` (loud "qualify the call" diagnostic), exactly one + differing from the winner → bind it. Routed the **primary call path** and the + call's **parameter target typing** (so a `*T`-param shadow gets implicit + address-of, not a value bit-cast to a pointer → segfault). + +- **0102d — the four remaining bare-name sites.** Routed the SAME resolver + through every other site that resolved a bare callee/function-name by + first-wins, each gated exactly as the call path (plain top-level identifier, + no scope-mangle / UFCS alias / local shadow; act on `.func` / `.ambiguous`, + fall through on `.none`): + 1. **Default-argument expansion** (`expandCallDefaults`): omitted trailing + args fill from the RESOLVED author's defaults, not the winner's. + 2. **Function-value conversion** (`closure(fn)` and the bare-fn-as-value + `func_ref` / fn-ptr / closure-coercion path): captures the resolved + author's FuncId. The winner's body is lazily lowered ONLY on the `.none` + fallback — a rerouted value never uses the winner, so taking a shadow as a + value must not pre-lower (and possibly mis-diagnose) the winner's body. + 3. **Free-function UFCS** (`recv.fn()` → `fn(recv, …)`): dispatches the + resolved author for the receiver's source. + 4. **Comptime `#run`** of a bare call: `lowerMainAndComptime` now sets + `current_source_file` per decl, so a `NAME :: #run f()` in an imported + module resolves `f` from THAT module's flat imports (own-author wins) + rather than the main file's perspective (where two flat authors made it + spuriously `.ambiguous` and failed the build). + +## Regression tests + +`examples/0722`–`0735` (each a focused multi-file flat-collision scene that +fails on pre-fix code and passes after): + +- `0722-modules-flat-same-name-own` — own-author wins on the call path. +- `0723-modules-flat-vs-namespaced` — a flat author + a namespaced same-name + author don't collide. +- `0724-modules-flat-same-name-ambiguous` — `≥2` flat authors, bare call → + loud diagnostic. +- `0725-modules-flat-dir-same-name` — flat **directory** import collision. +- `0726-modules-flat-same-name-variadic` — per-source variadic packing. +- `0728-modules-flat-same-name-paramtype` — per-source parameter target typing + (value vs pointer param). +- `0729-modules-flat-same-name-foreign` — same-name `#foreign` authors are NOT + rerouted (non-plain authors keep first-wins). +- `0730-modules-flat-same-name-default-arg` — per-source default-arg expansion. +- `0731-modules-flat-same-name-closure` — per-source `closure(fn)` + bare + fn-value capture. +- `0732-modules-flat-same-name-ufcs` — per-source free-function UFCS dispatch. +- `0733-modules-flat-same-name-comptime-run` — per-source comptime `#run` + callee. +- `0734-modules-flat-same-name-ufcs-ambiguous` — `≥2` flat authors, UFCS call + → loud diagnostic (pre-fix: silently bound the winner). +- `0735-modules-flat-same-name-fn-value-winner` — the first-wins winner's body + is independently broken and never used; a shadow taken as a function value + binds the shadow and runs while the winner is NOT lowered (pre-fix: the + fn-value site eagerly lowered the winner before the resolver rerouted, + surfacing the winner's error for a function the value never touches). diff --git a/readme.md b/readme.md index 9e4a688..7ddb706 100644 --- a/readme.md +++ b/readme.md @@ -393,6 +393,12 @@ Direct C header import: math :: #import "modules/math.sx"; // namespaced import ``` +When two flat-imported modules each define a function of the same name, every +module's own code binds its OWN author — a bare call resolves to the same-name +function in the caller's module (or in its single flat import that provides it). +A bare call to a name that two or more flat imports both provide is ambiguous and +is rejected; qualify it with a namespaced import (`m :: #import …; m.fn()`). + ### Implicit Context Every program gets an implicit `context` with a default allocator: diff --git a/src/core.zig b/src/core.zig index fdc9cd1..9cc4229 100644 --- a/src/core.zig +++ b/src/core.zig @@ -26,6 +26,12 @@ pub const Compilation = struct { import_sources: std.StringHashMap([:0]const u8), module_scopes: std.StringHashMap(std.StringHashMap(void)), import_graph: std.StringHashMap(std.StringHashMap(void)), + /// Flat-only subset of `import_graph` (bare `#import` edges, no namespaced + /// `ns :: #import`). Borrowed by `ProgramIndex.flat_import_graph`. + flat_import_graph: std.StringHashMap(std.StringHashMap(void)), + /// Per-module authored-function index (`path → name → *const FnDecl`). + /// Borrowed by `ProgramIndex.module_fns`. + module_fns: imports.ModuleFns, ir_emitter: ?ir.LLVMEmitter = null, /// Lowered IR module, kept alive past `generateCode` so post-link /// callbacks can re-enter the interpreter to invoke sx functions @@ -52,6 +58,8 @@ pub const Compilation = struct { .import_sources = std.StringHashMap([:0]const u8).init(allocator), .module_scopes = std.StringHashMap(std.StringHashMap(void)).init(allocator), .import_graph = std.StringHashMap(std.StringHashMap(void)).init(allocator), + .flat_import_graph = std.StringHashMap(std.StringHashMap(void)).init(allocator), + .module_fns = imports.ModuleFns.init(allocator), .target_config = target_config, .stdlib_paths = stdlib_paths, }; @@ -101,6 +109,7 @@ pub const Compilation = struct { &self.diagnostics, self.stdlib_paths, &self.import_graph, + &self.flat_import_graph, self.comptimeContext(), ) catch return error.CompileError; @@ -111,6 +120,11 @@ pub const Compilation = struct { self.module_scopes.put(entry.key_ptr.*, entry.value_ptr.scope) catch {}; } + // Per-module authored-function index, built from the SAME modules as + // `module_scopes` (main + every cache entry). Keyed by path; same-name + // cross-module authors stay distinct under their own paths. + imports.buildModuleFns(self.allocator, self.file_path, mod, &cache, &self.module_fns) catch {}; + // Store main file source in import_sources so error reporting can find it self.import_sources.put(self.file_path, self.source) catch {}; @@ -276,6 +290,8 @@ pub const Compilation = struct { lowering.diagnostics = &self.diagnostics; lowering.program_index.module_scopes = &self.module_scopes; lowering.program_index.import_graph = &self.import_graph; + lowering.program_index.flat_import_graph = &self.flat_import_graph; + lowering.program_index.module_fns = &self.module_fns; lowering.lowerRoot(root); if (self.diagnostics.hasErrors()) return error.CompileError; diff --git a/src/imports.test.zig b/src/imports.test.zig new file mode 100644 index 0000000..73a34a2 --- /dev/null +++ b/src/imports.test.zig @@ -0,0 +1,188 @@ +// Tests for imports.zig — flat-import name-resolution data retention (fix-0102a). + +const std = @import("std"); +const ast = @import("ast.zig"); +const parser = @import("parser.zig"); +const imports = @import("imports.zig"); + +var g_test_threaded: ?std.Io.Threaded = null; +fn testIo() std.Io { + if (g_test_threaded == null) { + g_test_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{}); + } + return g_test_threaded.?.io(); +} + +// Two flat-imported modules each author `greet`; a third is namespaced. The +// step retains BOTH `greet` authors under their own paths in `module_fns` and +// records the namespaced import in `import_graph` but NOT in `flat_import_graph` +// — WITHOUT touching the merged scope: `mod.decls` stays byte-for-byte +// first-wins (one `greet`, a.sx's), exactly as on `wt-fix-0102-base`. +test "imports: module_fns retains same-name cross-module fns; flat_import_graph excludes namespaced edge" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const io = testIo(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(io, .{ .sub_path = "a.sx", .data = "greet :: () -> s64 { 1 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "greet :: () -> s64 { 2 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "nsmod.sx", .data = "helper :: () -> s64 { 3 }\n" }); + const main_src = + \\#import "a.sx"; + \\#import "b.sx"; + \\ns :: #import "nsmod.sx"; + \\main :: () -> s32 { 0 } + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = main_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 a_path = try std.fmt.allocPrint(alloc, "{s}/a.sx", .{absdir}); + const b_path = try std.fmt.allocPrint(alloc, "{s}/b.sx", .{absdir}); + const ns_path = try std.fmt.allocPrint(alloc, "{s}/nsmod.sx", .{absdir}); + + const main_bytes = try std.Io.Dir.readFileAlloc(.cwd(), io, main_path, alloc, .limited(1 << 20)); + const main_source = try alloc.dupeZ(u8, main_bytes); + 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_fns = imports.ModuleFns.init(alloc); + try imports.buildModuleFns(alloc, main_path, mod, &cache, &module_fns); + + // The MERGED scope the first-wins resolver consumes is unchanged: mergeFlat + // still drops the second `greet`, so `mod.decls` carries exactly ONE — and + // it is a.sx's author (the first flat import), not b.sx's. + var greet_count: usize = 0; + var merged_greet: ?*const ast.FnDecl = null; + for (mod.decls) |decl| { + const name = decl.data.declName() orelse continue; + if (!std.mem.eql(u8, name, "greet")) continue; + greet_count += 1; + if (decl.data == .fn_decl) merged_greet = &decl.data.fn_decl; + } + try std.testing.expectEqual(@as(usize, 1), greet_count); + + // module_fns retains BOTH authors of `greet`, keyed by their own paths — + // the dropped author is recorded here (side index), not in the merged scope. + const a_fns = module_fns.get(a_path) orelse return error.MissingAFns; + const b_fns = module_fns.get(b_path) orelse return error.MissingBFns; + const a_greet = a_fns.get("greet") orelse return error.MissingAGreet; + const b_greet = b_fns.get("greet") orelse return error.MissingBGreet; + // Distinct authoring decls — not the same node deduped down to one. + try std.testing.expect(a_greet != b_greet); + // First-wins: the surviving merged-scope `greet` is a.sx's author. + try std.testing.expect(merged_greet == a_greet); + + // flat_import_graph carries the two bare `#import` edges, NOT the + // namespaced `ns :: #import` edge. + const flat = flat_import_graph.get(main_path) orelse return error.MissingFlatEdges; + try std.testing.expect(flat.contains(a_path)); + try std.testing.expect(flat.contains(b_path)); + try std.testing.expect(!flat.contains(ns_path)); + + // The full import_graph DOES record the namespaced edge (the contrast that + // makes the flat-graph exclusion meaningful). + const full = import_graph.get(main_path) orelse return error.MissingFullEdges; + try std.testing.expect(full.contains(a_path)); + try std.testing.expect(full.contains(b_path)); + try std.testing.expect(full.contains(ns_path)); +} + +// Mixed collision: a.sx authors `Widget` as a STRUCT (non-fn), b.sx authors it +// as a FUNCTION. fix-0102a must NOT let the function-author retention shift the +// merged scope — first-wins keeps a.sx's struct and drops b.sx's function, +// exactly as on `wt-fix-0102-base`. (The fn author may still be indexed in +// module_fns; resolution is what must be untouched.) +test "imports: mixed non-fn/fn same-name collision stays first-wins in merged scope" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const io = testIo(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(io, .{ .sub_path = "a.sx", .data = "Widget :: struct { x: s64 }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "Widget :: () -> s64 { 7 }\n" }); + const main_src = + \\#import "a.sx"; + \\#import "b.sx"; + \\main :: () -> s32 { 0 } + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = main_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_bytes = try std.Io.Dir.readFileAlloc(.cwd(), io, main_path, alloc, .limited(1 << 20)); + const main_source = try alloc.dupeZ(u8, main_bytes); + 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, + .{}, + ); + + // Exactly ONE `Widget` survives the merged scope, and it is a.sx's STRUCT — + // the function author did not displace or duplicate it. + var widget_count: usize = 0; + var merged_is_struct = false; + for (mod.decls) |decl| { + const name = decl.data.declName() orelse continue; + if (!std.mem.eql(u8, name, "Widget")) continue; + widget_count += 1; + merged_is_struct = decl.data == .struct_decl; + } + try std.testing.expectEqual(@as(usize, 1), widget_count); + try std.testing.expect(merged_is_struct); +} diff --git a/src/imports.zig b/src/imports.zig index 8517b4f..a2943be 100644 --- a/src/imports.zig +++ b/src/imports.zig @@ -5,6 +5,18 @@ const errors = @import("errors.zig"); const c_import = @import("c_import.zig"); const Node = ast.Node; +/// The `*const ast.FnDecl` a function-authoring decl carries, or null when the +/// decl is not a function — either a bare `fn_decl` (`f :: (…) -> T { … }`) or a +/// `const_decl` whose value is a function. Drives the per-module `module_fns` +/// identity index (fix-0102a). +fn fnDeclOf(decl: *const Node) ?*const ast.FnDecl { + return switch (decl.data) { + .fn_decl => &decl.data.fn_decl, + .const_decl => |cd| if (cd.value.data == .fn_decl) &cd.value.data.fn_decl else null, + else => null, + }; +} + /// 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 @@ -392,6 +404,43 @@ pub const ResolvedModule = struct { /// Module cache: maps resolved file paths to their ResolvedModules. pub const ModuleCache = std.StringHashMap(ResolvedModule); +/// Per-module function identity index: function NAME → the `*const FnDecl` that +/// module AUTHORS. Mirrors a single module's slice of `module_scopes`. +pub const FnIndex = std.StringHashMap(*const ast.FnDecl); + +/// `path → name → *const FnDecl`, mirroring `module_scopes`. One entry per +/// resolved module keyed by its path (a directory's combined module keyed by +/// `dir_path`); each entry indexes only what that module AUTHORS. Two modules +/// each authoring `f` are retained under their own paths — the identity index +/// fix-0102c's bare-name disambiguation consults to bind a flat call to the +/// right author. +pub const ModuleFns = std.StringHashMap(FnIndex); + +/// Index a single module's authored functions (`own_decls`) into `out[path]`. +/// First-wins WITHIN a module mirrors the scan pass; cross-module same-name +/// authors live under their own `path` keys. +fn indexModuleFns(allocator: std.mem.Allocator, out: *ModuleFns, path: []const u8, own_decls: []const *Node) !void { + const gop = try out.getOrPut(path); + if (!gop.found_existing) gop.value_ptr.* = FnIndex.init(allocator); + for (own_decls) |decl| { + const fd = fnDeclOf(decl) orelse continue; + const name = decl.data.declName() orelse continue; + if (gop.value_ptr.contains(name)) continue; + try gop.value_ptr.put(name, fd); + } +} + +/// Build the per-module function index from a resolved program: the main module +/// (keyed by `main_path`) plus every cached module (keyed by its own path). +/// Mirrors how `core.zig` fills `module_scopes` from `mod.scope` + the cache. +pub fn buildModuleFns(allocator: std.mem.Allocator, main_path: []const u8, main_mod: ResolvedModule, cache: *const ModuleCache, out: *ModuleFns) !void { + try indexModuleFns(allocator, out, main_path, main_mod.own_decls); + var it = cache.iterator(); + while (it.next()) |entry| { + try indexModuleFns(allocator, out, entry.key_ptr.*, entry.value_ptr.own_decls); + } +} + pub fn resolveImports( allocator: std.mem.Allocator, io: std.Io, @@ -404,6 +453,7 @@ pub fn resolveImports( 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 @@ -414,6 +464,15 @@ pub fn resolveImports( 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 "…"`. fix-0102c's 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 = &.{}, @@ -536,6 +595,17 @@ pub fn resolveImports( 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; @@ -563,7 +633,7 @@ pub fn resolveImports( // 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, comptime_ctx); + 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 @@ -571,7 +641,7 @@ pub fn resolveImports( break :blk result; } else |_| { // File read failed — try as directory import - const result = resolveDirectoryImport(allocator, io, resolved_path, chain, cache, source_map, diagnostics, decl.span, stdlib_paths, import_graph, comptime_ctx) catch { + 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}); } @@ -605,6 +675,7 @@ fn resolveDirectoryImport( 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 @@ -679,7 +750,7 @@ fn resolveDirectoryImport( }; 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, comptime_ctx); + 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); diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 765e36d..d827be3 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -10,6 +10,9 @@ const Ref = ir_mod.Ref; const FuncId = ir_mod.FuncId; const Lowering = ir_mod.Lowering; +const parser = @import("../parser.zig"); +const imports = @import("../imports.zig"); + test "lower: simple function with arithmetic" { const alloc = std.testing.allocator; var module = ir_mod.Module.init(alloc); @@ -1264,3 +1267,180 @@ test "lower: reflectionArgIsType accepts spelled types, rejects plain values (is try std.testing.expect(!l.reflectionArgIsType(&float_node)); try std.testing.expect(!l.reflectionArgIsType(&bool_node)); } + +var g_lower_test_threaded: ?std.Io.Threaded = null; +fn lowerTestIo() std.Io { + if (g_lower_test_threaded == null) { + g_lower_test_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{}); + } + return g_lower_test_threaded.?.io(); +} + +/// Count functions named `name` that carry a REAL body (promoted from the extern +/// stub: not `is_extern`, at least one basic block). +fn countRealBodies(module: *ir_mod.Module, name: []const u8) usize { + var n: usize = 0; + for (module.functions.items) |func| { + if (!std.mem.eql(u8, module.types.getString(func.name), name)) continue; + if (func.is_extern) continue; + if (func.blocks.items.len == 0) continue; + n += 1; + } + return n; +} + +// fix-0102b: two flat-imported modules each author `greet`. The first-wins merge +// keeps a.sx's author in the merged decl list (the WINNER) and drops b.sx's, +// which `module_fns` still retains (0102a). `main` itself can't bare-call `greet` +// — under fix-0102c two flat authors make that ambiguous — so it calls a.sx's +// `use_greet` wrapper, whose own-author call to `greet` binds a.sx's winner. +// BEFORE the identity-addressable pass, only the winner has a real body — the +// shadowed author has no slot at all (the pre-fix symptom: one `greet`). +// `lowerRetainedSameNameAuthors` declares the shadowed author its OWN same-name +// FuncId and lowers its body there, so BOTH authors carry distinct, non-extern +// bodies, and `resolveFuncByName` still returns the winner (the name-keyed slot). +test "lower: shadowed same-name author gets its own FuncId + real body (fix-0102b)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + const io = lowerTestIo(); + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(io, .{ .sub_path = "a.sx", .data = "greet :: () -> s64 { 1 }\nuse_greet :: () -> s64 { greet() }\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "b.sx", .data = "greet :: () -> s64 { 2 }\n" }); + const main_src = + \\#import "a.sx"; + \\#import "b.sx"; + \\main :: () -> s64 { use_greet() } + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "main.sx", .data = main_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_bytes = try std.Io.Dir.readFileAlloc(.cwd(), io, main_path, alloc, .limited(1 << 20)); + const main_source = try alloc.dupeZ(u8, main_bytes); + 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, + .{}, + ); + + // Per-module visibility scopes + authored-function index, wired exactly as + // `core.zig` does before `lowerRoot`. + 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 module_fns = imports.ModuleFns.init(alloc); + try imports.buildModuleFns(alloc, main_path, mod, &cache, &module_fns); + + const resolved_root = try alloc.create(Node); + resolved_root.* = .{ .span = root.span, .data = .{ .root = .{ .decls = mod.decls } } }; + + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + 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_fns = &module_fns; + + lowering.lowerRoot(resolved_root); + try std.testing.expect(!diagnostics.hasErrors()); + + // Pre-fix symptom: only the winner `greet` (a.sx) has a real body — lowered + // because `main` calls it; the shadowed author (b.sx) was dropped entirely. + try std.testing.expectEqual(@as(usize, 1), countRealBodies(&module, "greet")); + + // Identity-addressable pass: the shadowed author gets its OWN FuncId + body. + lowering.lowerRetainedSameNameAuthors(); + try std.testing.expect(!diagnostics.hasErrors()); + + // Both `greet` authors now carry distinct, real (non-extern) bodies, and the + // two FuncIds are distinct. + try std.testing.expectEqual(@as(usize, 2), countRealBodies(&module, "greet")); + + const name_id = module.types.internString("greet"); + var first: ?FuncId = null; + var second: ?FuncId = null; + for (module.functions.items, 0..) |func, i| { + if (func.name != name_id) continue; + if (func.is_extern or func.blocks.items.len == 0) continue; + if (first == null) first = FuncId.fromIndex(@intCast(i)) else second = FuncId.fromIndex(@intCast(i)); + } + try std.testing.expect(first != null and second != null); + try std.testing.expect(first.? != second.?); + + // F1 (attempt-2): the identity map must be keyed by the STABLE AST field + // pointer for BOTH same-name authors — the exact pointers `fn_ast_map` and + // `module_fns` carry — not a per-iteration switch-capture temporary. If the + // winner were keyed by `&fd` (the scanDecls bug), this lookup by the stable + // `fn_ast_map` pointer would miss (null). fix-0102c routes calls through + // exactly these pointers, so the round-trip must hold here. + const winner_fd = lowering.program_index.fn_ast_map.get("greet").?; + const winner_fid = lowering.fn_decl_fids.get(winner_fd); + try std.testing.expect(winner_fid != null); + // Round-trips to the first-wins winner FuncId (resolveFuncByName's pick). + try std.testing.expectEqual(lowering.resolveFuncByName("greet").?, winner_fid.?); + + // The shadowed author's stable pointer lives in `module_fns`; find the one + // that is NOT the winner and confirm IT round-trips to a DISTINCT FuncId. + var shadow_fd: ?*const ast.FnDecl = null; + var mf_it = module_fns.iterator(); + while (mf_it.next()) |path_entry| { + if (path_entry.value_ptr.get("greet")) |fd| { + if (fd != winner_fd) shadow_fd = fd; + } + } + try std.testing.expect(shadow_fd != null); + const shadow_fid = lowering.fn_decl_fids.get(shadow_fd.?); + try std.testing.expect(shadow_fid != null); + try std.testing.expect(shadow_fid.? != winner_fid.?); + + // fix-0102c: THE bare-name resolver routes per caller file. `main` flat- + // imports two `greet` authors and is its own author of neither → a bare + // `greet()` from `main` is ambiguous. a.sx authors the WINNER, so its bare + // `greet` resolves through the existing path (`.none`). b.sx authors the + // SHADOW, so own-author-wins binds b.sx's distinct FuncId — not first-wins. + const a_path = try std.fmt.allocPrint(alloc, "{s}/a.sx", .{absdir}); + const b_path = try std.fmt.allocPrint(alloc, "{s}/b.sx", .{absdir}); + try std.testing.expect(lowering.resolveBareCallee("greet", main_path) == .ambiguous); + try std.testing.expect(lowering.resolveBareCallee("greet", a_path) == .none); + switch (lowering.resolveBareCallee("greet", b_path)) { + .func => |resolved| try std.testing.expectEqual(shadow_fid.?, resolved.fid), + else => return error.TestUnexpectedResult, + } + // A name no module authors (and no flat import provides) never routes. + try std.testing.expect(lowering.resolveBareCallee("nonexistent", b_path) == .none); +} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 12231f5..ed02032 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -126,6 +126,17 @@ pub const Lowering = struct { comptime_param_nodes: ?std.StringHashMap(*const Node) = null, // active comptime substitutions target_type: ?TypeId = null, // target type for struct/enum literals without explicit names lowered_functions: std.StringHashMap(void), // tracks which functions have been fully lowered + /// Identity map: authoring `*const ast.FnDecl` → the FuncId `declareFunction` + /// created for it. The name-keyed function table (`resolveFuncByName`) returns + /// the FIRST author of a name, so two same-name authors collide there; this + /// map addresses each author's OWN slot by decl identity (fix-0102b), letting + /// a SHADOWED author lower its body into a distinct FuncId. + fn_decl_fids: std.AutoHashMap(*const ast.FnDecl, FuncId), + /// FuncId-keyed lowered tracking — the identity twin of `lowered_functions` + /// (which keys by name). A shadowed same-name author shares the winner's name + /// but not its FuncId, so name-keyed tracking can't tell them apart; this + /// records which specific FuncIds have had a real body lowered (fix-0102b). + lowered_fids: std.AutoHashMap(FuncId, void), local_fn_counter: u32 = 0, // unique counter for mangling local function names /// Declaration-name / import / visibility facts (architecture phase A1, /// `ProgramIndex`). Owns `import_flags`; borrows `module_scopes` / @@ -286,12 +297,89 @@ pub const Lowering = struct { ret_var_name: ?[]const u8, }; + /// Caller-state protection for lowering a function body re-entrantly — a + /// lazily lowered callee, a qualified `ns.fn` alias, or an out-of-line + /// same-name author. `enter` snapshots the in-progress builder / scope / + /// flag / pack / jni state and installs a fresh set for the nested body; + /// `restore` puts the caller's state back. Lowering a callee must be + /// transparent to the caller's own lowering — notably `block_terminated`, + /// which leaking back would mark the caller's trailing statements + /// dead-after-terminator (issue 0100 F2). + const FnBodyReentry = struct { + l: *Lowering, + func: ?FuncId, + block: ?BlockId, + counter: u32, + scope: ?*Scope, + defer_base: usize, + block_terminated: bool, + force_block_value: bool, + source_file: ?[]const u8, + jni_env_base: usize, + pack_arg_nodes: ?std.StringHashMap([]const *const Node), + pack_param_count: ?std.StringHashMap(u32), + pack_arg_types: ?std.StringHashMap([]const TypeId), + inline_return_target: ?InlineReturnInfo, + + fn enter(l: *Lowering) FnBodyReentry { + const g = FnBodyReentry{ + .l = l, + .func = l.builder.func, + .block = l.builder.current_block, + .counter = l.builder.inst_counter, + .scope = l.scope, + .defer_base = l.func_defer_base, + .block_terminated = l.block_terminated, + .force_block_value = l.force_block_value, + .source_file = l.current_source_file, + .jni_env_base = l.jni_env_stack_base, + .pack_arg_nodes = l.pack_arg_nodes, + .pack_param_count = l.pack_param_count, + .pack_arg_types = l.pack_arg_types, + .inline_return_target = l.inline_return_target, + }; + // The `#jni_env` Ref stack is lexical to ONE function's instruction + // stream; move the visible base to the current top. Pack-fn mono + // state is likewise lexical to the pack-fn body — null it so a + // callee sharing a param NAME with the active pack doesn't fold the + // outer mono's arity into its own `.len`. + l.jni_env_stack_base = l.jni_env_stack.items.len; + l.pack_arg_nodes = null; + l.pack_param_count = null; + l.pack_arg_types = null; + l.inline_return_target = null; + l.func_defer_base = l.defer_stack.items.len; + l.block_terminated = false; + l.force_block_value = false; + return g; + } + + fn restore(g: FnBodyReentry) void { + const l = g.l; + l.setCurrentSourceFile(g.source_file); + l.scope = g.scope; + l.func_defer_base = g.defer_base; + l.block_terminated = g.block_terminated; + l.force_block_value = g.force_block_value; + l.builder.func = g.func; + l.builder.current_block = g.block; + l.builder.inst_counter = g.counter; + l.jni_env_stack_base = g.jni_env_base; + l.pack_arg_nodes = g.pack_arg_nodes; + l.pack_param_count = g.pack_param_count; + l.pack_arg_types = g.pack_arg_types; + l.inline_return_target = g.inline_return_target; + } + }; + pub fn init(module: *Module) Lowering { return .{ .module = module, .builder = Builder.init(module), .alloc = module.alloc, .lowered_functions = std.StringHashMap(void).init(module.alloc), + .fn_decl_fids = std.AutoHashMap(*const ast.FnDecl, FuncId).init(module.alloc), + .lowered_fids = std.AutoHashMap(FuncId, void).init(module.alloc), .program_index = ProgramIndex.init(module.alloc), }; } @@ -703,8 +791,14 @@ pub const Lowering = struct { self.program_index.fn_ast_map.put(fd.name, &decl.data.fn_decl) catch {}; self.program_index.import_flags.put(fd.name, is_imported) catch {}; } - // Declare extern stub for all functions (bodies lowered lazily) - self.declareFunction(&fd, fd.name); + // Declare extern stub for all functions (bodies lowered + // lazily). Key the identity map (`fn_decl_fids`, inside + // `declareFunction`) by the STABLE AST field pointer — the + // same `&decl.data.fn_decl` stored in `fn_ast_map` and + // `module_fns` — not the switch-capture copy `fd`, whose + // address is a per-iteration stack temporary that no later + // decl-identity lookup can reproduce. + self.declareFunction(&decl.data.fn_decl, fd.name); }, .const_decl => |cd| { if (cd.value.data == .fn_decl) { @@ -1339,6 +1433,14 @@ pub const Lowering = struct { /// Pass 2: Lower main function body and comptime side-effects. fn lowerMainAndComptime(self: *Lowering, decls: []const *const Node) void { for (decls) |decl| { + // A `#run` body lowers in its OWN module's source context (fix-0102d + // site 4): `NAME :: #run f()` written in an imported module must + // resolve a bare `f` from that module's flat imports, not the main + // file's. Without this, `resolveBareCallee` runs with the main + // file's perspective and reports a genuine per-source author as + // ambiguous. Mirrors `scanDecls` / `lowerDecls`, which already set + // the source file per decl. + self.setCurrentSourceFile(decl.source_file); switch (decl.data) { .const_decl => |cd| { if (cd.value.data == .fn_decl) { @@ -1367,6 +1469,174 @@ pub const Lowering = struct { } } + /// Lower every SHADOWED same-name function author into its OWN FuncId with a + /// real (non-extern) body — the identity-addressable lowering PATH this step + /// adds (fix-0102b). It does NOT run during a default compile: the name path + /// stays the sole resolver, so the suite is byte-for-byte unchanged. fix-0102c + /// invokes it as part of routing bare flat calls to the right author; until + /// then it is exercised by the lower-test regression that asserts two distinct + /// non-extern bodies for a same-name collision. + /// + /// The first-wins flat/directory merge keeps exactly one author per name in + /// the merged decl list; `scanDecls` declares that WINNER (lowered on demand + /// through the name-keyed `lazyLowerFunction`). fix-0102a retained every + /// dropped same-name author in `module_fns` (path → name → `*FnDecl`) without + /// touching resolution; this walks that index and gives each shadowed author + /// its own slot: `declareFunction` (identity-mapped to a fresh same-name + /// FuncId) + `lowerFunctionBodyInto` (its body, in its own module's + /// visibility context). Two same-name authors then carry distinct FuncIds and + /// distinct bodies, while `resolveFuncByName` still returns the first (winner) + /// author so existing calls bind first-wins. + /// + /// Scoped to DIRECT flat imports of the main file: a `module_fns` entry whose + /// path is the main file or one of its bare `#import` edges. A namespaced + /// (`ns :: #import`) author has no bare-name winner and is excluded both by + /// that flat-edge gate and by the `fn_ast_map` winner lookup below. + pub fn lowerRetainedSameNameAuthors(self: *Lowering) void { + const module_fns = self.program_index.module_fns orelse return; + const main_file = self.main_file orelse return; + const flat_graph = self.program_index.flat_import_graph orelse return; + const main_flat_edges = flat_graph.get(main_file); + + var path_it = module_fns.iterator(); + while (path_it.next()) |path_entry| { + const path = path_entry.key_ptr.*; + const is_eligible = std.mem.eql(u8, path, main_file) or + (main_flat_edges != null and main_flat_edges.?.contains(path)); + if (!is_eligible) continue; + + var fn_it = path_entry.value_ptr.iterator(); + while (fn_it.next()) |fn_entry| { + const name = fn_entry.key_ptr.*; + const fd = fn_entry.value_ptr.*; + + // A name with no bare winner is namespaced-only (`ns.fn`) — it + // never participated in the flat merge, so it has no shadow to + // lower. The author already owning the name-keyed slot (the + // first-wins winner) lowers through the normal lazy path. + const winner = self.program_index.fn_ast_map.get(name) orelse continue; + if (winner == fd) continue; + + // Only plain free functions get an out-of-line slot; generic / + // foreign / builtin / #compiler authors keep their existing + // dispatch (mirrors lazyLowerFunction / declareFunction guards). + if (!isPlainFreeFn(fd)) continue; + + _ = self.bareAuthorFuncId(fd, name, path); + } + } + } + + /// Result of bare-call disambiguation (fix-0102c). + pub const BareCallee = union(enum) { + /// Bind the call to this specific author — its identity-addressable + /// FuncId (fix-0102b's `bareAuthorFuncId`) AND its `*FnDecl`. The decl + /// travels with the FuncId so every callee-signature decision in the + /// call path (variadic packing, …) reads the RESOLVED author, never a + /// first-wins re-lookup by name (fix-0102c F1). + func: ResolvedAuthor, + /// ≥2 distinct flat authors are reachable from the caller and none is + /// the caller's own — the bare call can't pick one; require a qualifier. + ambiguous, + /// 0 or 1 reachable author, or the resolved author IS the existing + /// bare-name winner — defer to the existing path, byte-for-byte. + none, + }; + + /// A resolved bare-call author: its FuncId and the `*FnDecl` that defined + /// it, kept together so the call path has ONE source of truth for the + /// callee (no re-fetch by name after resolution). + pub const ResolvedAuthor = struct { fid: FuncId, decl: *const ast.FnDecl }; + + /// THE bare-name call resolver (fix-0102c). One canonical traversal over + /// fix-0102a's `module_fns` + `flat_import_graph` that routes a bare + /// identifier call `name` from `caller_file` to the right same-name author + /// when flat imports introduce a genuine collision. Every single-author / + /// local / parameter / std / qualified name resolves through the EXISTING + /// path unchanged: the resolver returns `.none` whenever the outcome would + /// match first-wins, so nothing on the common path is perturbed. + /// + /// - **own-author wins**: if `caller_file` authors `name` and the bare-name + /// first-wins winner is a DIFFERENT author, bind the caller's own author. + /// (When the winner already IS the caller's own — the single-author and + /// first-importer cases — `.none` lets the existing path bind it.) + /// - else collect the authors reachable via `caller_file`'s FLAT import + /// edges (bare `#import` of a file or directory, never a namespaced + /// `ns :: #import`), deduped by `FnDecl` identity (a diamond import of the + /// same module is one author): `≥2 distinct` → `.ambiguous`; exactly one + /// that DIFFERS from the winner → bind it; otherwise `.none`. + /// + /// Generic / comptime / foreign / builtin authors are never rerouted — the + /// existing dispatch owns those shapes — so the resolver returns `.none`. + pub fn resolveBareCallee(self: *Lowering, name: []const u8, caller_file: []const u8) BareCallee { + const module_fns = self.program_index.module_fns orelse return .none; + const winner = self.program_index.fn_ast_map.get(name); + + // own-author wins. + if (module_fns.get(caller_file)) |own_fns| { + if (own_fns.get(name)) |own| { + if (winner != null and winner.? == own) return .none; + if (!isPlainFreeFn(own)) return .none; + return .{ .func = .{ .fid = self.bareAuthorFuncId(own, name, caller_file), .decl = own } }; + } + } + + // Caller does not author `name` → collect its flat-reachable authors. + const flat_graph = self.program_index.flat_import_graph orelse return .none; + const edges = flat_graph.get(caller_file) orelse return .none; + var distinct = std.AutoHashMap(*const ast.FnDecl, []const u8).init(self.alloc); + defer distinct.deinit(); + var edge_it = edges.iterator(); + while (edge_it.next()) |e| { + const fns = module_fns.get(e.key_ptr.*) orelse continue; + // Only plain free functions are eligible for rerouting; generic / + // foreign / builtin / #compiler authors keep their existing + // dispatch. Filtering BEFORE the count gate means a same-name + // collision of non-plain authors (e.g. two flat-imported modules + // each `#foreign`ing the same symbol) is NOT counted as ambiguous — + // it falls through to `.none` and the existing first-wins path. + if (fns.get(name)) |fd| { + if (!isPlainFreeFn(fd)) continue; + distinct.put(fd, e.key_ptr.*) catch {}; + } + } + if (distinct.count() == 0) return .none; + if (distinct.count() >= 2) return .ambiguous; + + var one_it = distinct.iterator(); + const entry = one_it.next().?; + const the_one = entry.key_ptr.*; + const the_path = entry.value_ptr.*; + if (winner != null and winner.? == the_one) return .none; + return .{ .func = .{ .fid = self.bareAuthorFuncId(the_one, name, the_path), .decl = the_one } }; + } + + /// The FuncId for a resolved bare-call author, ensuring its body is lowered. + /// Only ever called for a SHADOW (an author that is not the name-keyed + /// winner): the winner owns the name-keyed slot and lowers through the + /// normal lazy path, so `resolveBareCallee` returns `.none` for it. A shadow + /// is declared a fresh same-name FuncId in its OWN module's visibility + /// context and its body lowered into that slot via fix-0102b's identity- + /// addressable `lowerFunctionBodyInto`. Idempotent: `lowered_fids` tracks + /// which slots already carry a body. + fn bareAuthorFuncId(self: *Lowering, fd: *const ast.FnDecl, name: []const u8, path: []const u8) FuncId { + if (self.fn_decl_fids.get(fd)) |fid| { + if (!self.lowered_fids.contains(fid)) { + self.lowered_fids.put(fid, {}) catch {}; + self.lowerFunctionBodyInto(fd, fid, name); + } + return fid; + } + const saved_src = self.current_source_file; + self.setCurrentSourceFile(path); + self.declareFunction(fd, name); + self.setCurrentSourceFile(saved_src); + const fid = self.fn_decl_fids.get(fd).?; + self.lowered_fids.put(fid, {}) catch {}; + self.lowerFunctionBodyInto(fd, fid, name); + return fid; + } + /// Declare a function as an extern stub (signature only, no body). pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8) void { // Skip generic templates — they're monomorphized on demand, not declared as extern @@ -1421,6 +1691,7 @@ pub const Lowering = struct { func.is_variadic = is_variadic; func.has_implicit_ctx = wants_ctx; self.foreign_name_map.put(name, c_name) catch {}; + self.fn_decl_fids.put(fd, fid) catch {}; return; } } @@ -1432,6 +1703,7 @@ pub const Lowering = struct { func.source_file = self.current_source_file; func.is_variadic = is_variadic; func.has_implicit_ctx = wants_ctx; + self.fn_decl_fids.put(fd, fid) catch {}; } /// Register a namespaced import's OWN functions under their module-qualified @@ -1581,73 +1853,11 @@ pub const Lowering = struct { // Mark as lowered before lowering (prevents infinite recursion) self.lowered_functions.put(name, {}) catch {}; - // Save builder state (same pattern as lambda lowering) - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - const saved_scope = self.scope; - const saved_defer_base = self.func_defer_base; - const saved_block_terminated = self.block_terminated; - const saved_force_block_value = self.force_block_value; - const saved_source_file = self.current_source_file; - // Lowering a callee must be transparent to the caller's lowering - // state: restore the FULL saved context on EVERY exit path through one - // defer so the three exits (non-null branch, already-promoted early - // return, null-FuncId `ns.fn` alias branch) cannot drift. Notably - // `block_terminated` — a qualified alias whose body terminates (e.g. a - // constant-folded `if true { return … }`) leaves it true, and leaking - // that into the caller marks the caller's own trailing statements - // dead-after-terminator (issue 0100 F2). The jni/pack/foreign-class - // fields keep their own defers above. - defer { - self.setCurrentSourceFile(saved_source_file); - self.scope = saved_scope; - self.func_defer_base = saved_defer_base; - self.block_terminated = saved_block_terminated; - self.force_block_value = saved_force_block_value; - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - // The `#jni_env` Ref stack is lexical within ONE function's instruction - // stream — Refs from the caller don't dereference correctly in this - // callee's body. Move the visible base to the current top so - // omitted-env `#jni_call` in this fn doesn't accidentally pick up the - // caller's Refs. Defer covers all the early-return paths below. - const saved_jni_env_base = self.jni_env_stack_base; - self.jni_env_stack_base = self.jni_env_stack.items.len; - defer self.jni_env_stack_base = saved_jni_env_base; - // Pack-fn mono state is lexical to the pack-fn body. A lazily - // lowered callee may share a param NAME with the active pack - // (e.g. `walk(args: []Any)` called from `probe(..$args)`); without - // isolation, `lowerFieldAccess`'s `.len` intercept - // folds the callee's `args.len` to the outer mono's arity and - // bakes the constant into the IR. Same shape for the AST-node - // and per-element-type maps. Null out for the duration of the - // body lowering and restore on exit. - const saved_pan = self.pack_arg_nodes; - const saved_ppc = self.pack_param_count; - const saved_pat = self.pack_arg_types; - const saved_iri = self.inline_return_target; - self.pack_arg_nodes = null; - self.pack_param_count = null; - self.pack_arg_types = null; - self.inline_return_target = null; - defer { - self.pack_arg_nodes = saved_pan; - self.pack_param_count = saved_ppc; - self.pack_arg_types = saved_pat; - self.inline_return_target = saved_iri; - } - self.func_defer_base = self.defer_stack.items.len; - self.block_terminated = false; - self.force_block_value = false; - - // Find the existing extern stub and replace it with a full body + // Find the existing extern stub (from scanDecls), keyed by NAME — the + // FIRST author of a name owns this slot. A shadowed same-name author is + // not here (it has no name-keyed slot); it is lowered out-of-line into + // its OWN FuncId by `lowerRetainedSameNameAuthors` (fix-0102b). const name_id = self.module.types.internString(name); - const ret_ty = self.resolveReturnType(fd); - - // Look up the existing function declaration (from scanDecls) var func_id: ?FuncId = null; for (self.module.functions.items, 0..) |func, i| { if (func.name == name_id) { @@ -1656,100 +1866,118 @@ pub const Lowering = struct { } } - if (func_id == null) { - // Function not yet declared — create it fresh via lowerFunction. - // A module-qualified alias (`ns.fn`, issue 0100) is registered in - // `fn_ast_map` without an eager `declareFunction`, so there's no - // `Function.source_file` to switch to (the path above). Restore the - // alias's OWN declaring source before lowering its body, otherwise - // it lowers in the caller's visibility context and an own-import - // callee (`foo` calling `helper` from `foo`'s module's flat import) - // is reported "not visible" (issue 0100 F1). - if (self.program_index.qualified_fn_source.get(name)) |src| { - self.setCurrentSourceFile(src); - } - self.lowerFunction(fd, name, false); - return; // caller state restored by the top-level defer - } - if (func_id) |fid| { - // Re-use the existing function slot — switch builder to it - self.builder.func = fid; - const func = &self.module.functions.items[@intFromEnum(fid)]; - self.setCurrentSourceFile(func.source_file); - if (!func.is_extern) { - // Already promoted (e.g., via lowerComptimeDeps) — skip. - // Caller state restored by the top-level defer. - return; - } - func.is_extern = false; // promote from extern stub to real function - func.linkage = if (isExportedEntryName(name)) .external else .internal; - if (fd.call_conv == .c) func.call_conv = .c; - // Set inst_counter to param count (params occupy refs 0..N-1). - // IR params = AST params + 1 if the function carries `__sx_ctx` - // at slot 0. - const ctx_slots: usize = if (func.has_implicit_ctx) 1 else 0; - std.debug.assert(func.params.len == fd.params.len + ctx_slots); - self.builder.inst_counter = @intCast(func.params.len); - - // Create entry block - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - // Create scope and bind params - var scope = Scope.init(self.alloc, null); - defer scope.deinit(); - self.scope = &scope; - - // The implicit `__sx_ctx` param (when present) lives at slot 0; - // user params shift by one. `current_ctx_ref` is bound to slot 0 - // so call-site lowering can prepend it to every sx-to-sx call. - // For OS-called entry points (main / JNI hooks), there's no - // ctx param at all — we synthesise `&__sx_default_context` and - // bind `current_ctx_ref` to its address so the body's sx-to-sx - // calls have a sensible Context to forward. - const wants_ctx = self.funcWantsImplicitCtx(fd); - const saved_ctx_ref = self.current_ctx_ref; - defer self.current_ctx_ref = saved_ctx_ref; - const user_param_base: u32 = if (wants_ctx) 1 else 0; - if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0); - - for (fd.params, 0..) |p, i| { - const pty = self.resolveParamType(&p); - const slot = self.builder.alloca(pty); - const param_ref = Ref.fromIndex(@intCast(i + user_param_base)); - self.builder.store(slot, param_ref); - scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true }); - } - - // Inbound entry points and callconv(.c) sx functions: bind - // current_ctx_ref to the static default before any user code - // runs. C-callable sx functions don't get a __sx_ctx param, - // but their bodies may call ctx-aware sx functions / fn-ptrs - // and need a real Context to forward. - if (!wants_ctx and self.implicit_ctx_enabled) { - if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| { - self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, self.module.types.ptrTo(.void)); - } - } - - // Lower the function body (set target_type to return type for implicit returns) - const saved_target = self.target_type; - self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null; - if (ret_ty != .void and ret_ty != .noreturn) { - self.lowerValueBody(fd.body, ret_ty); - } else { - // void / noreturn: no value to return — lower as statements and - // let `ensureTerminator` close the block (ret void / unreachable). - self.lowerBlock(fd.body); - self.ensureTerminator(ret_ty); - } - self.target_type = saved_target; - - self.builder.finalize(); + self.lowerFunctionBodyInto(fd, fid, name); + return; } - // Caller state restored by the top-level defer. + + // Function not yet declared — create it fresh via lowerFunction. A + // module-qualified alias (`ns.fn`, issue 0100) is registered in + // `fn_ast_map` without an eager `declareFunction`, so there's no + // `Function.source_file` to switch to. Restore the alias's OWN declaring + // source before lowering its body, otherwise it lowers in the caller's + // visibility context and an own-import callee (`foo` calling `helper` + // from `foo`'s module's flat import) is reported "not visible" (0100 F1). + // The reentry guard keeps the nested lowering transparent to the caller. + var reentry = FnBodyReentry.enter(self); + defer reentry.restore(); + if (self.program_index.qualified_fn_source.get(name)) |src| { + self.setCurrentSourceFile(src); + } + self.lowerFunction(fd, name, false); + } + + /// Lower `fd`'s body into the SPECIFIC `fid`, promoting its extern stub to a + /// real function. Identity-addressable: the caller passes the exact FuncId, + /// so a SHADOWED same-name author lowers into its OWN slot instead of + /// colliding on the name-keyed `resolveFuncByName` (which returns the first + /// author, the very split that trips issue 0100's param-count assert). Self- + /// contained — the `FnBodyReentry` guard makes the nested lowering + /// transparent to any in-progress caller body (issue 0100 F2) — so it serves + /// both `lazyLowerFunction`'s name-keyed found path and the out-of-line + /// `lowerRetainedSameNameAuthors` pass. + fn lowerFunctionBodyInto(self: *Lowering, fd: *const ast.FnDecl, fid: FuncId, name: []const u8) void { + // objc-defined-class method context for `*Self` substitution (M1.2 A.2b); + // the resolveReturnType / resolveParamType calls below consult it. + const saved_fc = self.current_foreign_class; + defer self.current_foreign_class = saved_fc; + if (self.lookupObjcDefinedClassForMethod(name)) |fcd| { + self.current_foreign_class = fcd; + } + + var reentry = FnBodyReentry.enter(self); + defer reentry.restore(); + + const ret_ty = self.resolveReturnType(fd); + + // Re-use the existing function slot — switch builder to it. + self.builder.func = fid; + const func = &self.module.functions.items[@intFromEnum(fid)]; + self.setCurrentSourceFile(func.source_file); + if (!func.is_extern) { + // Already promoted (e.g., via lowerComptimeDeps) — skip. + return; + } + func.is_extern = false; // promote from extern stub to real function + func.linkage = if (isExportedEntryName(name)) .external else .internal; + if (fd.call_conv == .c) func.call_conv = .c; + // Set inst_counter to param count (params occupy refs 0..N-1). IR params + // = AST params + 1 if the function carries `__sx_ctx` at slot 0. + const ctx_slots: usize = if (func.has_implicit_ctx) 1 else 0; + std.debug.assert(func.params.len == fd.params.len + ctx_slots); + self.builder.inst_counter = @intCast(func.params.len); + + // Create entry block + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // Create scope and bind params + var scope = Scope.init(self.alloc, null); + defer scope.deinit(); + self.scope = &scope; + + // The implicit `__sx_ctx` param (when present) lives at slot 0; user + // params shift by one. `current_ctx_ref` is bound to slot 0 so call-site + // lowering can prepend it to every sx-to-sx call. For OS-called entry + // points (main / JNI hooks) there's no ctx param — synthesise + // `&__sx_default_context` and bind `current_ctx_ref` to its address. + const wants_ctx = self.funcWantsImplicitCtx(fd); + const saved_ctx_ref = self.current_ctx_ref; + defer self.current_ctx_ref = saved_ctx_ref; + const user_param_base: u32 = if (wants_ctx) 1 else 0; + if (wants_ctx) self.current_ctx_ref = Ref.fromIndex(0); + + for (fd.params, 0..) |p, i| { + const pty = self.resolveParamType(&p); + const slot = self.builder.alloca(pty); + const param_ref = Ref.fromIndex(@intCast(i + user_param_base)); + self.builder.store(slot, param_ref); + scope.put(p.name, .{ .ref = slot, .ty = pty, .is_alloca = true }); + } + + // Inbound entry points + callconv(.c) sx functions: bind current_ctx_ref + // to the static default before any user code runs. + if (!wants_ctx and self.implicit_ctx_enabled) { + if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| { + self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, self.module.types.ptrTo(.void)); + } + } + + // Lower the function body (set target_type to return type for implicit returns) + const saved_target = self.target_type; + self.target_type = if (ret_ty != .void and ret_ty != .noreturn) ret_ty else null; + if (ret_ty != .void and ret_ty != .noreturn) { + self.lowerValueBody(fd.body, ret_ty); + } else { + // void / noreturn: no value to return — lower as statements and let + // `ensureTerminator` close the block (ret void / unreachable). + self.lowerBlock(fd.body); + self.ensureTerminator(ret_ty); + } + self.target_type = saved_target; + + self.builder.finalize(); } /// Lower a single function declaration. @@ -3022,10 +3250,36 @@ pub const Lowering = struct { const str = self.builder.constString(sid); break :blk self.builder.boxAny(str, .string); } - if (!self.lowered_functions.contains(eff_fn_name)) { - self.lazyLowerFunction(eff_fn_name); - } - if (self.resolveFuncByName(eff_fn_name)) |fid| { + // fix-0102d site 2: taking a bare same-name fn as a VALUE + // (func_ref, fn-ptr / closure coercion) must capture the + // RESOLVED author's FuncId for a genuine flat collision, not + // the first-wins winner's. Plain bare name only; `.ambiguous` + // → loud diagnostic; `.none` → existing first-wins path. The + // winner is lazily lowered ONLY on `.none` — a rerouted value + // never uses the winner, so its body must not be lowered. + const value_fid: ?FuncId = blk_fv: { + if (std.mem.eql(u8, eff_fn_name, id.name) and + self.program_index.ufcs_alias_map.get(id.name) == null and + (if (self.scope) |scope| scope.lookup(id.name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(id.name, caller_file)) { + .func => |resolved| break :blk_fv resolved.fid, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, node.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{id.name}); + break :blk self.emitError(id.name, node.span); + }, + .none => {}, + } + } + } + if (!self.lowered_functions.contains(eff_fn_name)) { + self.lazyLowerFunction(eff_fn_name); + } + break :blk_fv self.resolveFuncByName(eff_fn_name); + }; + if (value_fid) |fid| { // Auto-promote bare function → closure when target_type is closure if (self.target_type) |tt| { if (!tt.isBuiltin()) { @@ -7052,10 +7306,32 @@ pub const Lowering = struct { // If argument is a bare function name, create a proper closure from it if (arg.data == .identifier) { const fn_name = arg.data.identifier.name; - if (!self.lowered_functions.contains(fn_name)) { - self.lazyLowerFunction(fn_name); - } - if (self.resolveFuncByName(fn_name)) |fid| { + // fix-0102d site 2: `closure(fn)` over a genuine flat same-name + // collision must capture the RESOLVED author's FuncId, not the + // first-wins winner's. Plain bare name only; `.ambiguous` + // → loud diagnostic; `.none` → existing first-wins path. + const closure_fid: ?FuncId = blk_cl: { + if (self.program_index.ufcs_alias_map.get(fn_name) == null and + (if (self.scope) |scope| scope.lookup(fn_name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(fn_name, caller_file)) { + .func => |resolved| break :blk_cl resolved.fid, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, arg.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fn_name}); + return Ref.none; + }, + .none => {}, + } + } + } + if (!self.lowered_functions.contains(fn_name)) { + self.lazyLowerFunction(fn_name); + } + break :blk_cl self.resolveFuncByName(fn_name); + }; + if (closure_fid) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; // Build closure type from user-visible params only — // skip the implicit __sx_ctx param. @@ -7338,6 +7614,41 @@ pub const Lowering = struct { } } } + // fix-0102c: a genuine flat same-name collision — bind the + // caller file's OWN author (or its single flat-reachable + // author), or reject a bare call to a name ≥2 imported modules + // author. Only a plain top-level identifier call routes here: + // scope-mangled / UFCS-aliased / locally-shadowed names and + // 0/1-author names fall straight to the existing path below + // (`resolveBareCallee` returns `.none`). + if (std.mem.eql(u8, func_name, id.name) and + (if (self.scope) |scope| scope.lookup(id.name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(func_name, caller_file)) { + .none => {}, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name}); + return Ref.none; + }, + .func => |resolved| { + const fid = resolved.fid; + const func = &self.module.functions.items[@intFromEnum(fid)]; + const ret_ty = func.ret; + const params = func.params; + // The RESOLVED author's decl drives variadic + // packing — not a first-wins re-lookup by name, + // whose variadic shape may differ (fix-0102c F1). + self.packVariadicCallArgs(resolved.decl, c, &args); + const final_args = self.prependCtxIfNeeded(func, args.items); + self.coerceCallArgs(final_args, params); + if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len); + return self.builder.call(fid, final_args, ret_ty); + }, + } + } + } // Check for comptime-expanded or generic functions if (self.program_index.fn_ast_map.get(func_name)) |fd| { if (hasComptimeParams(fd)) { @@ -7820,12 +8131,33 @@ pub const Lowering = struct { // `recv.fn(args)` → `fn(recv, args)`). Lazily lower the body — // a function reached ONLY via UFCS would otherwise be declared // but never emitted (issue 0063: undefined symbol at link). - if (self.program_index.fn_ast_map.get(fa.field)) |_| { - if (!self.lowered_functions.contains(fa.field)) { - self.lazyLowerFunction(fa.field); + // + // fix-0102d site 3: a free-function UFCS target with a genuine + // flat same-name collision must dispatch to the RESOLVED author + // for the receiver's source, not the first-wins winner. The + // field name is never scope-mangled, so the only gate is a + // known source file; `.ambiguous` → loud diagnostic; `.none` + // → existing first-wins path. + const ufcs_fid: ?FuncId = blk_uf: { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(fa.field, caller_file)) { + .func => |resolved| break :blk_uf resolved.fid, + .ambiguous => { + if (self.diagnostics) |d| + d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field}); + return Ref.none; + }, + .none => {}, + } } - } - if (self.resolveFuncByName(fa.field)) |fid| { + if (self.program_index.fn_ast_map.get(fa.field)) |_| { + if (!self.lowered_functions.contains(fa.field)) { + self.lazyLowerFunction(fa.field); + } + } + break :blk_uf self.resolveFuncByName(fa.field); + }; + if (ufcs_fid) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; const ret_ty = func.ret; const params = func.params; @@ -11611,6 +11943,24 @@ pub const Lowering = struct { } break :blk2 scoped; }; + // fix-0102d site 1: for a genuine flat same-name collision the + // omitted trailing args must be filled from the RESOLVED + // author's defaults, not the first-wins winner's. Only a plain + // top-level identifier with no scope-mangle / UFCS alias / + // local shadow routes here; `.ambiguous` declines to expand + // (the call path emits the single diagnostic); `.none` keeps + // the existing first-wins winner, byte-for-byte. + if (std.mem.eql(u8, eff_name, id.name) and + (if (self.scope) |scope| scope.lookup(id.name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(id.name, caller_file)) { + .func => |resolved| break :blk resolved.decl, + .ambiguous => return null, + .none => {}, + } + } + } break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null; }, // Namespace call `mod.fn(args)` — args map directly to params @@ -11833,6 +12183,29 @@ pub const Lowering = struct { break :blk scoped; }; + // fix-0102c F2: a genuine flat same-name collision must type this + // call's args against the RESOLVED author's params, not the first-wins + // winner's. Mirror the `lowerCall` routing one layer earlier so arg + // lowering (implicit address-of, coercion) matches the author actually + // called — otherwise a `*T`-param shadow gets a `T` value arg that is + // later bit-cast to a pointer (segfault). Only a plain top-level + // identifier with no scope-mangle / UFCS alias / local shadow routes + // here; `.ambiguous` / `.none` fall to the existing first-wins path so + // single-author / local / std resolution is byte-for-byte unchanged. + if (std.mem.eql(u8, name, bare_name) and + (if (self.scope) |scope| scope.lookup(bare_name) == null else true)) + { + if (self.current_source_file) |caller_file| { + switch (self.resolveBareCallee(bare_name, caller_file)) { + .func => |resolved| { + const func = &self.module.functions.items[@intFromEnum(resolved.fid)]; + return self.userParamTypes(func); + }, + .ambiguous, .none => {}, + } + } + } + // Check declared functions if (self.resolveFuncByName(name)) |fid| { const func = &self.module.functions.items[@intFromEnum(fid)]; @@ -11890,6 +12263,19 @@ pub const Lowering = struct { return false; } + /// A plain free function: no type params (not generic) and an ordinary sx + /// body (not `#foreign` / `#builtin` / `#compiler`). Only these get an + /// out-of-line identity-addressable slot — the bare-call disambiguation + /// (fix-0102c) and the shadow-author lowering pass leave every other shape + /// to the existing name-keyed dispatch. + fn isPlainFreeFn(fd: *const ast.FnDecl) bool { + if (fd.type_params.len > 0) return false; + return switch (fd.body.data) { + .foreign_expr, .builtin_expr, .compiler_expr => false, + else => true, + }; + } + /// Pack-fn: has a trailing heterogeneous pack param (`is_variadic /// AND is_comptime`). Mixed shapes — non-pack comptime params /// before the pack — are also accepted; the mono folds those diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index 6b6cc82..2612b18 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -1,5 +1,6 @@ const std = @import("std"); const ast = @import("../ast.zig"); +const imports = @import("../imports.zig"); const types = @import("types.zig"); const inst = @import("inst.zig"); const errors = @import("../errors.zig"); @@ -570,9 +571,10 @@ pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// `self.program_index.`; later phases hand collaborator modules a /// `*ProgramIndex` instead of `*Lowering`. /// -/// OWNS the declaration maps below. BORROWS `module_scopes` / `import_graph` -/// (pointers into maps owned by the compilation driver, `core.zig`) — those -/// are read-only views and are never freed here. +/// OWNS the declaration maps below. BORROWS `module_scopes` / `import_graph` / +/// `flat_import_graph` / `module_fns` (pointers into maps owned by the +/// compilation driver, `core.zig`) — those are read-only views and are never +/// freed here. /// /// Per-map allocators are preserved exactly as they were on `Lowering`: /// `import_flags` / `fn_ast_map` / `global_names` use the lowering allocator @@ -587,6 +589,16 @@ pub const ProgramIndex = struct { /// Module path → set of directly imported paths (param_impl visibility /// filter). Borrowed view. import_graph: ?*std.StringHashMap(std.StringHashMap(void)) = null, + /// Module path → set of directly FLAT-imported paths — the subset of + /// `import_graph` edges from a bare `#import` (never a namespaced + /// `ns :: #import`). fix-0102c's bare-name disambiguation walks this to + /// decide which same-name authors a flat importer can reach. Borrowed view. + flat_import_graph: ?*std.StringHashMap(std.StringHashMap(void)) = null, + /// Module path → (function name → authoring `*const FnDecl`), mirroring + /// `module_scopes`. Retains every same-name author under its own path so + /// fix-0102c can resolve a flat call to the right module's function. + /// Borrowed view. + module_fns: ?*imports.ModuleFns = null, // ── Declaration maps ── /// Function name → AST decl. @@ -627,7 +639,8 @@ pub const ProgramIndex = struct { } pub fn deinit(self: *ProgramIndex) void { - // Owned maps only — module_scopes / import_graph are borrowed. + // Owned maps only — module_scopes / import_graph / flat_import_graph / + // module_fns are borrowed. self.import_flags.deinit(); self.fn_ast_map.deinit(); self.qualified_fn_source.deinit(); diff --git a/src/root.zig b/src/root.zig index faf2280..767d19f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -13,6 +13,7 @@ pub const trace_runtime_tests = @import("runtime_trace.test.zig"); pub const sema = @import("sema.zig"); pub const sema_tests = @import("sema.test.zig"); pub const imports = @import("imports.zig"); +pub const imports_tests = @import("imports.test.zig"); pub const core = @import("core.zig"); pub const c_import = @import("c_import.zig"); pub const ir = @import("ir/ir.zig");