Compare commits

...

15 Commits

Author SHA1 Message Date
agra
db7af02950 Merge branch 'wt-fix-0102-base'
Some checks failed
Build / build-linux (push) Has been cancelled
Build / build-windows (push) Has been cancelled
2026-06-06 16:51:44 +03:00
agra
1c6ea7d84e Merge branch 'flow/distribution/fix-0102d' into wt-fix-0102-base 2026-06-06 16:51:29 +03:00
agra
96e3d2d5ae fix(lower): fn-value site lazily lowers winner only on resolver .none [0102d F1]
The bare-fn-as-value site (func_ref / fn-ptr / closure coercion) eagerly
lazily-lowered the name-keyed first-wins WINNER before the resolveBareCallee
block could reroute a genuine flat same-name collision to its per-source
author. Taking a SHADOW author's fn value therefore lowered (and could
mis-diagnose) the unused winner's body. Move lazyLowerFunction INSIDE blk_fv
onto the `.none` fallback only, mirroring the closure(fn) and free-function
UFCS sites: on `.func` use the resolved author's FuncId and never touch the
winner; on `.none` fall through to lazy-lower + resolveFuncByName the winner.

Regression: examples/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 (exit 0) while the winner
is not lowered. Fails-before (unresolved symbol in the winner), passes-after.
2026-06-06 16:41:01 +03:00
agra
bd24996d8b fix(lower): route remaining bare-name sites through resolver + close 0102 [0102d]
Final 0102 sub-step. fix-0102c landed resolveBareCallee and routed the
primary call path + parameter target typing through it, leaving four other
bare-name consumer sites on the old first-wins path. Route the SAME resolver
through all four, 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 so single-author / local / std / qualified / foreign-single
resolution is byte-for-byte unchanged):

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.
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) instead
   of the main file's perspective (which made it spuriously ambiguous).

Regression tests: examples/0730-0734 (default-arg, closure+fn-value, UFCS,
comptime #run, UFCS-ambiguity), each fails on pre-fix code and passes after.
issues/0102-flat-import-same-signature-collision.md written RESOLVED with the
4-sub-step root cause and regression-test paths.
2026-06-06 16:16:57 +03:00
agra
b660ea6ed9 Merge branch 'flow/distribution/fix-0102c' into wt-fix-0102-base 2026-06-06 15:45:57 +03:00
agra
2131557669 fix(lower): bare-call resolver skips non-plain authors before ambiguity gate [0102c F3]
resolveBareCallee's flat-collect branch counted ALL same-name authors —
including #foreign / generic / builtin / #compiler — before the
isPlainFreeFn filter, so two flat-imported modules each #foreign-ing the
same libc symbol under one sx name returned `.ambiguous` and errored,
instead of falling to `.none` and the existing first-wins foreign path
(master behavior). Filter authors to plain free functions DURING
collection, before the count/ambiguity determination: a non-plain
collision now yields 0 reroutable authors -> `.none`; genuine plain-fn
collisions still yield >= 2 -> `.ambiguous` (0724 unchanged). The
now-redundant single-author isPlainFreeFn check is dropped.

Regression: examples/0729-modules-flat-same-name-foreign — two flat FILE
imports each #foreign the same libc "abs" under name `absval`; a bare
call resolves first-wins and runs (exit 0). Fails-before on this branch
(ambiguity error), passes-after.
2026-06-06 15:31:14 +03:00
agra
8c88504849 fix(lower): resolved author drives call param target typing [0102c F2]
Attempt-3 fix for the F2 review finding. After resolveBareCallee picks a
shadowed same-name author at a normal call site, the call's PARAMETER TARGET
TYPING still ran first-wins: resolveCallParamTypes' bare-identifier branch
resolved param types via resolveFuncByName(name) / fn_ast_map.get(name) — both
keyed by name, not by the resolved author. Because that runs in lowerCall
BEFORE the resolveBareCallee routing, a shadow author whose parameter TYPE
differs from the first-wins winner had its args lowered against the WINNER's
signature (no implicit address-of for a *T param typed as T), then the
correctly-resolved shadow FuncId was called with the mis-typed arg — a value
bit-cast to a pointer → segfault.

The bare-identifier branch now routes through the SAME resolveBareCallee
resolver one layer earlier and takes the param target types from the RESOLVED
author's lowered func.params (userParamTypes). Only the .func (single resolved
author) outcome reroutes; .ambiguous keeps the existing loud call-site
diagnostic and .none keeps the first-wins fallback, so single-author / local /
std / qualified resolution is byte-for-byte unchanged. Method-call / namespace /
foreign / generic branches of resolveCallParamTypes are untouched. The resolver
is idempotent (bareAuthorFuncId guards body lowering via lowered_fids) so the
extra call from param-type resolution is safe; lowerFunctionBodyInto already
saves/restores all lowering state for mid-call reentry.

Regression: examples/0728-modules-flat-same-name-paramtype — two flat file
imports each author `apply` with a divergent param type (a.sx value `s64`
winner, b.sx pointer `*s64` shadow). b.sx's from_b passes a value local to its
pointer-param author via implicit address-of (×2 → 42); a.sx's from_a (own ==
winner) is unchanged (value + 1 → 11). Fails on the pre-fix typing (segfault at
from_b); passes after.

Gate (worktree): zig build, zig build test (400/400), bash tests/run_examples.sh
(464 passed / 0 failed) all green. Matrix 0722-0727 unchanged. Guardrail: m3te
builds via the worktree binary (sx build --target ios-sim, exit 0) — single-
author / local resolution intact. Default-arg / closure / UFCS / comptime SITES
remain first-wins (fix-0102d).
2026-06-06 15:07:51 +03:00
agra
8f9c00dcdb fix(lower): resolved author drives variadic packing in bare call [0102c F1]
Attempt-2 fix for the F1 review finding. After `resolveBareCallee` picks a
shadowed same-name author's FuncId at a normal call site, the call path still
re-fetched the FIRST-WINS function AST by name to drive variadic argument
packing. When the resolved (shadow) author's variadic shape differs from the
first-wins author's, arguments were packed against the WRONG signature — a
fixed-arity shadow packed as if variadic, or a variadic shadow not packed at
all — producing IR with the wrong argument count (LLVM verification failure).

The `.func` arm now carries the resolved `*FnDecl` alongside its FuncId
(`BareCallee.func: ResolvedAuthor`), so `packVariadicCallArgs` reads THE
resolved author's signature. The rest of the arm already used the resolved
FuncId's IR function (ret/params/ctx/coercion), so the callee now has one
source of truth in the whole call lowering — no re-fetch by name after
resolution. Default-arg / closure / UFCS / comptime *sites* remain first-wins
(fix-0102d); `expandCallDefaults` runs before resolution and is a default site.

Regression: examples/0726-modules-flat-same-name-variadic — two flat file
imports each author `combine` and `pick` with OPPOSITE variadic shapes (a.sx
fixed `combine` / variadic `pick`; b.sx variadic `combine` / fixed `pick`).
Each module's bare call must pack against ITS OWN author. Fails on the pre-fix
re-lookup (LLVM "Incorrect number of arguments passed to called function" for
both `combine.1` and `pick.2`); passes after.

Gate: zig build, zig build test (400/400), bash tests/run_examples.sh
(463 passed) all green. Matrix 0722-0725/0727 unchanged; single-author / local
resolution byte-for-byte unchanged (the `.func` arm never runs for them).
2026-06-06 14:28:00 +03:00
agra
ea35a05b26 fix(lower): bare-call resolver binds same-name flat authors per source [0102c]
Third of four fix-0102 sub-steps — the behaviour fix for NORMAL call sites.
Adds THE bare-name resolver `resolveBareCallee(name, caller_file)` over
fix-0102a's `module_fns` + `flat_import_graph` and routes the primary call
path through it:

- own-author wins: a file's bare call to a name IT authors binds its OWN
  author, not the first-wins merge winner. (When the winner already is the
  caller's own — every single-author and first-importer case — the resolver
  returns `.none` so the existing path binds it byte-for-byte.)
- a bare call to a name two or more FLAT imports both provide is `.ambiguous`
  and rejected with a loud diagnostic ("declared by multiple imported
  modules — qualify the call"); a namespaced author never collides.
- a single flat-reachable author that differs from the winner binds that
  author; otherwise `.none`.

The resolved shadow author lowers into its OWN FuncId via fix-0102b's
identity-addressable `lowerFunctionBodyInto` (shared `bareAuthorFuncId`
helper, also used by `lowerRetainedSameNameAuthors`). Only plain free
functions route — generic / comptime / foreign / builtin authors and any
scope-mangled / UFCS-aliased / locally-shadowed name fall straight to the
existing dispatch, so single-author / local / std / qualified resolution is
unchanged (full example suite stays green, including bundle.sx and the
comptime format/pack examples).

Examples 0722 (flat file per-source bind), 0723 (flat vs namespaced, no false
ambiguity), 0724 (ambiguous → diagnostic), 0725 (flat directory per-source
bind), 0727 (user namespace literally named __m0). Each fails on
wt-fix-0102-base (first-wins mis-bind / no diagnostic) and passes here. The
fix-0102b unit test now calls a per-module wrapper (main can't bare-call the
2-author name) and asserts the resolver's three variants directly.

Gate: zig build, zig build test (400/400), bash tests/run_examples.sh
(462 passed) all green.
2026-06-06 14:04:03 +03:00
agra
b077e8e29c Merge branch 'flow/distribution/fix-0102b' into wt-fix-0102-base 2026-06-06 13:28:44 +03:00
agra
bb1ed7294b fix(lower): key fn_decl_fids by stable AST pointer in scanDecls [0102b F1]
scanDecls declared a bare `.fn_decl` via `declareFunction(&fd, ...)`,
where `fd` is the switch-capture COPY of `decl.data.fn_decl`. Its
address is a per-iteration stack temporary, so the winner author's
`fn_decl_fids` entry was keyed by an address no later decl-identity
lookup can reproduce — `fn_ast_map` and `module_fns` carry the stable
`&decl.data.fn_decl`, so a lookup by that pointer missed the winner's
FuncId. fix-0102c routes calls through exactly these stable pointers,
so the key has to match.

Record the entry under `&decl.data.fn_decl` (the persistent AST node
field) to match `fn_ast_map`/`module_fns`. The other declareFunction
sites already pass stable pointers (const_decl field, module_fns entry,
fn_ast_map entry, struct-method node field, heap-synthesized objc decl);
`lowered_fids` keys by FuncId value, so neither has the temporary-address
mistake.

Strengthen the fix-0102b regression test: assert the identity map
round-trips by the STABLE pointer for BOTH same-name authors — the
winner's `fn_ast_map` pointer resolves to the first-wins FuncId, and the
shadow's `module_fns` pointer resolves to a distinct FuncId. This
assertion fails on the pre-fix code (winner keyed by `&fd` → null) and
passes after. Call resolution unchanged (name path still default).

Gate (this worktree): zig build, zig build test (400/400),
bash tests/run_examples.sh (457 passed) all green.
2026-06-06 13:22:11 +03:00
agra
237f794585 feat(lower): identity-addressable function body lowering [0102b]
Second of four fix-0102 sub-steps. Makes function declaration + body
lowering addressable by decl/FuncId IDENTITY instead of name-first-wins,
so two same-name authors can each carry their OWN body in their OWN
FuncId. Purely additive: the existing name path stays the sole resolver,
so the suite is byte-for-byte unchanged (no call rerouting — that is
0102c).

- declareFunction records `*const FnDecl -> FuncId` in a new identity map
  (`fn_decl_fids`), alongside the existing name-keyed function table.
- Extract the body-lowering tail of lazyLowerFunction into a reusable
  `lowerFunctionBodyInto(fd, fid, name)` that promotes a SPECIFIC extern
  stub into a real body by EXPLICIT FuncId — not by name lookup (which
  returns the first author). The shared save/restore preamble becomes a
  `FnBodyReentry` guard struct, used by both lazyLowerFunction's found
  path and the null-FuncId `ns.fn` alias path; issue-0100 F1/F2 behaviour
  (own-import source context, block_terminated transparency) is preserved.
- Add `lowerRetainedSameNameAuthors`: walks fix-0102a's `module_fns`,
  and for each SHADOWED flat author (a same-name author that is not the
  fn_ast_map winner, in a direct flat import of the main file) declares a
  fresh same-name FuncId + lowers its body in its own module's visibility
  context. FuncId-keyed `lowered_fids` tracks which slots already have a
  body. Not invoked during a default compile (the name path stays the
  default); 0102c wires it into bare-flat-call routing.
- lower.test.zig: regression that compiles two flat-imported modules each
  authoring `greet` and asserts ONE real body before the pass (winner
  only; shadow dropped) and TWO distinct non-extern bodies after — the
  shadowed author is no longer dropped/extern.

Gate (this worktree): zig build, zig build test (400/400),
bash tests/run_examples.sh (457 passed) all green.
2026-06-06 13:02:49 +03:00
agra
ccffbbb441 Merge branch 'flow/distribution/fix-0102a' into wt-fix-0102-base 2026-06-06 12:02:57 +03:00
agra
3dbc6f8434 fix(imports): keep merged scope first-wins; index dups in module_fns only [0102a]
Attempt-1 retained a same-name cross-module FUNCTION author in the merged
decl list (mergeFlat + the directory merge), which is the list the existing
first-wins resolver consumes. That changed the data feeding resolution
(`mod.decls` carried two `greet`), violating this step's purpose: additive
indexes with ZERO resolution change.

Revert both merge sites to byte-for-byte first-wins, exactly as on
wt-fix-0102-base. The dropped same-name author is still retained — but only
in the SEPARATE `module_fns` index, which is built from each module's
`own_decls` (un-deduped, per-path) and which nothing reads yet. The
`flat_import_graph` side data is likewise untouched. Both are foundation
for fix-0102c's bare-name disambiguation; current resolution is unchanged.

Drop the now-unused `declAuthorsFn` helper (its only callers were the two
merge sites). `fnDeclOf` stays — it feeds the index.

Tests: the existing unit test now asserts the merged scope stays first-wins
(one `greet`, a.sx's author) while `module_fns` still retains BOTH authors
and `flat_import_graph` excludes the namespaced edge. Add a mixed non-fn/fn
collision test asserting the merged scope keeps a.sx's struct (first-wins),
unchanged by the function author.
2026-06-06 11:53:16 +03:00
agra
ff9cb50079 refactor(imports): retain dup same-name fn authors + build identity indexes [0102a]
First of four fix-0102 sub-steps. Purely additive: retains data that the
flat/directory merge currently first-wins-drops and builds two identity
indexes for later bare-name disambiguation (fix-0102c). No resolution
change — the existing first-wins bare path still wins; suite unchanged.

- mergeFlat + directory merge: stop dropping a same-name FUNCTION authored
  by a different module/file. Non-function decls keep first-wins dedup; node
  identity dedup is untouched.
- flat_import_graph: a flat-only subset of import_graph, recording an edge
  only for a bare `#import` (imp.name == null), never a namespaced
  `ns :: #import`. Threaded through resolveImports/resolveDirectoryImport
  and into ProgramIndex.
- module_fns (path -> name -> *const FnDecl): per-module authored-function
  index mirroring module_scopes, built in core.zig from the main module +
  cache. Same-name cross-module authors stay distinct under their own paths.
- imports.test.zig: asserts both a.sx/b.sx greet authors are retained in
  module_fns and in the global flat list, and that flat_import_graph
  excludes the namespaced edge while import_graph includes it.

Gate (this worktree): zig build, zig build test (398/398),
bash tests/run_examples.sh (457 passed) all green.
2026-06-06 11:27:11 +03:00
94 changed files with 1604 additions and 180 deletions

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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(); }

View File

@@ -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
}

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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
}

View File

@@ -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; }

View File

@@ -0,0 +1,2 @@
// The second flat author of `dup`.
dup :: () -> s64 { return 2; }

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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(); }

View File

@@ -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
}

View File

@@ -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); }

View File

@@ -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); }

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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(); }

View File

@@ -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; }

View File

@@ -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
}

View File

@@ -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); }

View File

@@ -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; }

View File

@@ -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
}

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
// The second flat author of `absval` — the identical `#foreign` libc binding.
absval :: (n: s32) -> s32 #foreign libc "abs";

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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(); }

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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(); }

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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(); }

View File

@@ -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
}

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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
}

View File

@@ -0,0 +1,2 @@
// a.sx authors `dup` (+1). One of two distinct flat authors of `dup`.
dup :: (x: s64) -> s64 { return x + 1; }

View File

@@ -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; }

View File

@@ -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
}

View File

@@ -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(); }

View File

@@ -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();
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
from_a binds a.greet: ok
from_b binds b.greet: ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
bare binds flat: ok
nm.value binds named: ok

View File

@@ -0,0 +1 @@
1

View File

@@ -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());
| ^^^

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
caller1 binds d1.tag: ok
caller2 binds d2.tag: ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,4 @@
from_a combine fixed: ok
from_b combine variadic: ok
from_a pick variadic: ok
from_b pick fixed: ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
call_a binds a.ping: ok
call_b binds b.ping: ok
__m0.ping binds m.ping: ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,2 @@
from_a binds a.apply (value param): ok
from_b binds b.apply (pointer param): ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
absval = 7

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,2 @@
from_a binds a.cfg default (10): ok
from_b binds b.cfg default (20): ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -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

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
from_a v.bump() binds a.bump (+1): ok
from_b v.bump() binds b.bump (+100): ok

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,2 @@
a.sx #run binds a.compute (7): ok
b.sx #run binds b.compute (70): ok

View File

@@ -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());
| ^^^^^

View File

@@ -0,0 +1 @@
from_b_value = 2

View File

@@ -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).

View File

@@ -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:

View File

@@ -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;

188
src/imports.test.zig Normal file
View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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 `<name>.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 `<pack_name>.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

View File

@@ -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.<field>`; 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();

View File

@@ -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");