brink_analyzer/resolve.rs
1use std::collections::{BTreeMap, BTreeSet};
2
3use brink_format::DefinitionId;
4use brink_ir::{
5 Diagnostic, DiagnosticCode, FileId, Import, LocalSymbol, RefKind, ResolutionMap, ResolvedRef,
6 Scope, SymbolIndex, SymbolInfo, SymbolKind, SymbolManifest, Visibility,
7 is_reserved_root_module,
8};
9
10use crate::manifest::local_definition_id;
11
12/// Per-file import coverage, shared verbatim by resolution ([`ImportScope`])
13/// and the E025 import-required checker (`modules::import_covers`): the set
14/// of modules imported **qualified** (`IMPORT mod`, which licenses
15/// `module.name` access to any public export) and the `(module, name)` pairs
16/// brought into scope by **bare** imports (`IMPORT { name } FROM mod`, which
17/// is name-precise — it does *not* license every other export of `mod`).
18///
19/// A single source of truth for this distinction: an earlier version of
20/// `ImportScope` collapsed every import to just its module name, so a bare
21/// `IMPORT { other } FROM mod` was (wrongly) treated as importing *all* of
22/// `mod`, silently disagreeing with `import_covers`'s name-precise gate.
23///
24/// # Dual-reading a bare item's trailing segment (issue #1592)
25///
26/// `use story::market::barter;` lowers to a bare import whose sole item is
27/// `barter` "from" module `story::market` (`lower_native::import`'s
28/// item-is-the-leaf reading) — but Rust's `use`, which charter §13.2
29/// commits to lifting verbatim, dual-reads that trailing segment: `barter`
30/// may be an *item* `story::market` exports, or it may itself name the
31/// **module** `story::market::barter` (a real submodule, licensing
32/// **module-qualified** access to its own public exports — "a trailing
33/// segment that resolves to a module licenses that module, exactly as
34/// Rust's `use` does", per the #1592 ruling, `docs/decision-log.md`
35/// 2026-07-27). Same shape for every entry in a nested list (`use a::{b,
36/// c};` dual-reads `b` and `c` independently, exactly as `use a::b;`
37/// dual-reads `b`).
38///
39/// This is a **per-file** query (`resolve(FileId)`'s incremental contract:
40/// "reads only the symbol index and this file's own manifest — never
41/// another file's content") and has no way to know here whether
42/// `module::name` is a real declared module elsewhere in the project — that
43/// whole-project view exists only in `modules::check`. So both readings are
44/// licensed **unconditionally**: the item pairing is inserted into `bare`
45/// exactly as before, and `module::name` is *also* inserted into
46/// `qualified` as a phantom qualified-module candidate. This is a pure
47/// no-op unless some file's symbol genuinely carries that exact module
48/// name — `classify` only ever matches a *candidate's own* `info.module`
49/// against this set, so an unreal phantom module simply never matches
50/// anything. **Precedence decision (issue #1592, "decide and document"):
51/// both readings apply — there is no exclusion.** They populate disjoint,
52/// non-conflicting sets (`bare` is name-precise; `qualified` is
53/// module-wide), so a name that resolves as *both* an item of `module` and
54/// a module in its own right gets both: the item is bare-importable under
55/// its own name (via `bare`), and the submodule's public exports become
56/// reachable **qualified** — `barter::haggle`, never bare `haggle` (via
57/// `qualified`). This mirrors Rust's own per-namespace `use` semantics
58/// (a module and a value can share a name without conflict) without this
59/// codebase needing to model namespaces explicitly. `modules::check`'s
60/// `E088` is the check that validates *this* file's readings against real
61/// project-wide module/export data and diagnoses when a trailing segment
62/// resolves to **neither**.
63///
64/// ⚠ **Corrected 2026-08-05 (issue #2287), widened 2026-08-22 (issue
65/// #2298).** This doc previously claimed the submodule reading makes its
66/// exports "bare-visible" / "referenceable by bare name" — that was an
67/// over-read of the 2026-07-27 ruling and was itself the reported bug: a
68/// `qualified` entry alone must never license a *bare* reference
69/// (`is_qualified_import_only`, enforced via the shared
70/// `lookup_bare_excluding_qualified_only` helper both `lookup_divert`'s
71/// `Knot`/`Stitch`/`Label`/`Variable`+`Constant` steps and
72/// `resolve_function`'s knot-as-tunnel-function step now go through —
73/// #2287 shipped only the `Knot`-in-a-divert case; #2298 closed the
74/// deliberate remainder). `qualified_modules.contains(module)` still
75/// legitimately grants every *other* reference kind (calls resolving to an
76/// `External`/`Variable`/`Constant`, struct literals, etc.)
77/// `Candidacy::Imported` for their own qualified-syntax forms via the
78/// ordinary flat `classify`/`lookup_by_name` path; nothing about that
79/// changed.
80#[must_use]
81pub(crate) fn import_coverage_for_file(
82 imports: &[Import],
83) -> (BTreeSet<String>, BTreeSet<(&str, &str)>) {
84 let mut qualified = BTreeSet::new();
85 let mut bare = BTreeSet::new();
86 for import in imports {
87 if import.bare {
88 for item in &import.items {
89 bare.insert((import.module.as_str(), item.name.as_str()));
90 // Dual-reading (issue #1592, doc above): `item.name` might
91 // itself name a submodule of `import.module` rather than an
92 // item of it. `::`-joining is native's real module-path
93 // separator (`brink_db::modules::native_module_path`), but
94 // `#@module(...)` places no structural constraint on an ink
95 // module's own name either (it accepts any non-empty
96 // string, `::`-joined or not — see
97 // `modules::known_module_names`'s doc, corrected by the
98 // #1686 review), so this is *not* a structural ink no-op.
99 // It is a no-op only for the corpus this compiler actually
100 // has to stay byte-identical for — no `#@module`/`IMPORT`/
101 // `use` construct appears anywhere in the oracle/tier1
102 // corpus at all (`modules`'s own Compat doc).
103 //
104 // Unconditional on `item.alias`, deliberately: this phantom
105 // candidate is inserted whether or not the item was
106 // aliased, so an aliased trailing segment that resolves to
107 // a module (`use a::b as c;` where `b` is a submodule)
108 // still licenses qualified access to `b`'s exports under
109 // `b`'s *own* name (`b::item`, issue #2287), even though
110 // `c` binds nothing useful. That shape
111 // has no sound alias representation at all (aliasing a
112 // whole module's export set, not one name) and is
113 // diagnosed loudly at the whole-project level instead —
114 // `modules::check`'s `E129` fires when this pass's
115 // `is_module` reading and `item.alias.is_some()` coincide.
116 qualified.insert(format!("{}::{}", import.module, item.name));
117 }
118 } else {
119 qualified.insert(import.module.clone());
120 }
121 }
122 (qualified, bare)
123}
124
125/// Per-file import context threaded into resolution (M-2d,
126/// docs/modules-spec.md §2; issue #790) so a bare reference with multiple
127/// cross-module candidates binds to the module *this file* actually imports —
128/// "names cross module boundaries only via import" — rather than to the flat
129/// duplicate-winner.
130///
131/// The default (empty) scope reproduces the pre-M-2d flat behavior exactly:
132/// the entire strict-ink and single-module world has at most one candidate
133/// per (name, kind), so [`lookup_by_name`]'s fast path returns it unchanged
134/// and this context never influences the result. It only ever disambiguates
135/// the genuinely-new case unlocked by relaxing the #784/#793 stopgap: two
136/// *declared* modules publicly defining the same name, now coexisting in the
137/// index instead of one being suppressed.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct ImportScope {
140 /// The referring file's own **declared** module (`None` for an
141 /// undeclared stem-module / the legacy world). A candidate declared in
142 /// this same module is bare-visible without any import.
143 pub file_module: Option<String>,
144 /// Modules this file imports **qualified** (`IMPORT mod`) — licenses
145 /// `module.name` access to any public export of that module.
146 pub qualified_modules: BTreeSet<String>,
147 /// `(module, name)` pairs this file imports **bare**
148 /// (`IMPORT { name } FROM mod`) — name-precise, matching
149 /// `modules::import_covers` exactly so resolution and the E025
150 /// import-required diagnostic can never diverge. `BTreeSet` for
151 /// determinism. Keyed by the imported item's own (source-module) name
152 /// regardless of any local alias — an aliased import still *covers* its
153 /// source name for cross-module licensing purposes (§2: the file did
154 /// import it), it just isn't the name resolution binds bare (see
155 /// `aliases`).
156 pub bare_imports: BTreeSet<(String, String)>,
157 /// Local alias → `(module, source_name)` for every bare import item that
158 /// named one (`IMPORT { name AS alias } FROM mod` / `use mod::name as
159 /// alias;`, issue #1590). `index.by_name` is keyed by definitions' own
160 /// spellings only, so a plain [`lookup_by_name`] lookup can never find an
161 /// alias — this table is the indirection [`lookup_by_name`] falls back to
162 /// once the direct-name lookup comes up empty. Additive, not
163 /// shadowing: aliasing doesn't revoke the source name's own bare
164 /// visibility (still governed by `bare_imports`/`classify` exactly as
165 /// before) — it only adds a second local spelling for the same import.
166 /// See the doc comment on [`lookup_by_name`] for the alias-vs-original
167 /// licensing ruling. `BTreeMap` for determinism.
168 pub aliases: BTreeMap<String, (String, String)>,
169}
170
171impl ImportScope {
172 /// Build the scope for one file from its resolved (declared) module and
173 /// its HIR `IMPORT` list.
174 #[must_use]
175 pub fn new(file_module: Option<String>, imports: &[Import]) -> Self {
176 let (qualified, bare) = import_coverage_for_file(imports);
177 let mut aliases = BTreeMap::new();
178 for import in imports {
179 if !import.bare {
180 continue;
181 }
182 for item in &import.items {
183 if let Some(alias) = &item.alias {
184 aliases.insert(alias.clone(), (import.module.clone(), item.name.clone()));
185 }
186 }
187 }
188 Self {
189 file_module,
190 qualified_modules: qualified,
191 bare_imports: bare
192 .into_iter()
193 .map(|(module, name)| (module.to_string(), name.to_string()))
194 .collect(),
195 aliases,
196 }
197 }
198}
199
200/// How a candidate definition relates to the referring file's import scope.
201#[derive(Clone, Copy, PartialEq, Eq)]
202enum Candidacy {
203 /// Bare-visible without an import: the legacy world (`module == None`) or
204 /// a definition in the referrer's own declared module.
205 InScope,
206 /// A public definition in a **declared** module this file imports.
207 Imported,
208 /// Neither — a cross-module definition this file has no line of sight to.
209 Other,
210}
211
212/// Classify a candidate against the referring file's import scope (M-2d).
213fn classify(scope: &ImportScope, info: &SymbolInfo) -> Candidacy {
214 match &info.module {
215 // Undeclared stem-module / legacy soup — always bare-visible, so the
216 // pre-modules corpus is untouched.
217 None => Candidacy::InScope,
218 Some(module) => {
219 if scope.file_module.as_deref() == Some(module.as_str()) {
220 Candidacy::InScope
221 } else if info.visibility == Visibility::Public
222 && (scope.qualified_modules.contains(module)
223 || scope
224 .bare_imports
225 .contains(&(module.clone(), info.name.clone())))
226 {
227 Candidacy::Imported
228 } else {
229 Candidacy::Other
230 }
231 }
232 }
233}
234
235/// Resolve all unresolved references across files.
236///
237/// Per-file concatenation of [`resolve_file`], preserving input file order.
238/// The production orchestrator (`analyze_with_options`) drives the per-file
239/// [`crate::resolve`] query directly; this whole-project wrapper survives as
240/// a test convenience.
241#[cfg(test)]
242pub fn resolve_refs(
243 index: &SymbolIndex,
244 files: &[(FileId, &SymbolManifest)],
245) -> (ResolutionMap, Vec<Diagnostic>) {
246 let mut map = ResolutionMap::new();
247 let mut diagnostics = Vec::new();
248
249 let scope = ImportScope::default();
250 for &(file_id, manifest) in files {
251 let (file_map, file_diags) = resolve_file(index, &scope, file_id, manifest);
252 map.extend(file_map);
253 diagnostics.extend(file_diags);
254 }
255
256 (map, diagnostics)
257}
258
259/// Resolve one file's unresolved references against the project-wide index.
260///
261/// Reads only the symbol index and this file's own manifest — never another
262/// file's content. This is the per-file dependency seam the query pipeline
263/// relies on (substrate spec §4, layer 2 — `resolve(FileId)`).
264///
265/// Local (param/temp) lookups read `manifest.locals` — this file's own
266/// side table — rather than the project-wide index (issue #517): a knot's
267/// body lives in exactly one file, so a local can never legitimately be
268/// declared in one file and referenced from another. Scoping the lookup to
269/// this file's own locals both restores correct behavior for cross-file
270/// duplicate-scoped-locals (slice-A finding 4 — no more merged-index
271/// aliasing) and lets the project-wide `resolution_index` drop locals
272/// entirely, so a body edit that adds/removes a `~ temp` in file Y no
273/// longer invalidates file X's `resolve` memo.
274pub fn resolve_file(
275 index: &SymbolIndex,
276 scope: &ImportScope,
277 file_id: FileId,
278 manifest: &SymbolManifest,
279) -> (ResolutionMap, Vec<Diagnostic>) {
280 let mut map = ResolutionMap::new();
281 let mut diagnostics = Vec::new();
282 let locals = &manifest.locals;
283
284 for uref in &manifest.unresolved {
285 match uref.kind {
286 RefKind::Divert => {
287 resolve_divert(
288 index,
289 scope,
290 locals,
291 file_id,
292 uref,
293 &mut map,
294 &mut diagnostics,
295 );
296 }
297 RefKind::Variable => {
298 resolve_variable(
299 index,
300 scope,
301 locals,
302 file_id,
303 uref,
304 &mut map,
305 &mut diagnostics,
306 );
307 }
308 RefKind::Function => {
309 resolve_function(
310 index,
311 scope,
312 locals,
313 file_id,
314 uref,
315 &mut map,
316 &mut diagnostics,
317 );
318 }
319 RefKind::List => {
320 resolve_list_ref(index, scope, file_id, uref, &mut map, &mut diagnostics);
321 }
322 RefKind::Struct => {
323 resolve_struct_ref(index, scope, file_id, uref, &mut map, &mut diagnostics);
324 }
325 RefKind::Type => {
326 resolve_type_ref(index, scope, file_id, uref, &mut map);
327 }
328 }
329 }
330
331 (map, diagnostics)
332}
333
334fn resolve_divert(
335 index: &SymbolIndex,
336 scope: &ImportScope,
337 locals: &[LocalSymbol],
338 file_id: FileId,
339 uref: &brink_ir::UnresolvedRef,
340 map: &mut ResolutionMap,
341 diagnostics: &mut Vec<Diagnostic>,
342) {
343 if let Some(id) = lookup_divert(index, scope, locals, uref) {
344 map.push(ResolvedRef {
345 file: file_id,
346 range: uref.range,
347 target: id,
348 });
349 check_divert_arity(index, file_id, uref, id, diagnostics);
350 } else {
351 diagnostics.push(unresolved_diag(
352 index,
353 scope,
354 file_id,
355 uref.range,
356 &uref.path,
357 DiagnosticCode::E024,
358 &[
359 SymbolKind::Knot,
360 SymbolKind::Stitch,
361 SymbolKind::Label,
362 SymbolKind::Variable,
363 SymbolKind::Constant,
364 ],
365 ));
366 }
367}
368
369/// Check a divert-with-args site's argument count against its resolved
370/// target's declared parameter count (`E176`, issue #2156) — `E031`'s
371/// sibling for the divert call shape (`-> knot(args)`, a tunnel call, or a
372/// thread-start), extended to a construct `check_arity` never covered:
373/// `RefKind::Divert` refs always carried `arg_count: None` until this issue
374/// (`brink_ir::symbols::project::Projector::walk_divert_target`), so
375/// `check_arity` — gated on `arg_count.is_some()` — could never fire for a
376/// divert on either dialect regardless of how many arguments were given.
377///
378/// Scoped to a resolution naming a `Knot`/`Stitch`/`Label` — the only
379/// symbol kinds with their own declared parameter row — mirroring
380/// `resolve_function`'s own `check_arity` call sites, which likewise check
381/// only `External`/`Knot` resolutions and skip `Variable`/local ones. A
382/// divert resolving to a `Variable` (`-> x` where `x` holds a stored divert
383/// target, e.g. `docs/…/WritingWithInk.md`'s "Advanced: sending divert
384/// targets as parameters") or to a divert-typed local `Param` (`-> return_to`
385/// inside `=== knot(-> return_to) ===`) is an indirection whose real
386/// target's arity is not known statically at this site — `index.symbols`
387/// has no declared parameter row for either kind, so checking against it
388/// would misfire on legitimate code instead of silently doing nothing
389/// useful.
390fn check_divert_arity(
391 index: &SymbolIndex,
392 file_id: FileId,
393 uref: &brink_ir::UnresolvedRef,
394 target: DefinitionId,
395 diagnostics: &mut Vec<Diagnostic>,
396) {
397 let Some(call_arg_count) = uref.arg_count else {
398 return;
399 };
400 let Some(info) = index.symbols.get(&target) else {
401 return;
402 };
403 if !matches!(
404 info.kind,
405 SymbolKind::Knot | SymbolKind::Stitch | SymbolKind::Label
406 ) {
407 return;
408 }
409 let expected = info.params.len();
410 if call_arg_count != expected {
411 diagnostics.push(Diagnostic {
412 file: file_id,
413 range: uref.range,
414 message: format!(
415 "{}: `{}` expects {} argument(s), got {}",
416 DiagnosticCode::E176.title(),
417 uref.path,
418 expected,
419 call_arg_count,
420 ),
421 code: DiagnosticCode::E176,
422 });
423 }
424}
425
426fn lookup_divert(
427 index: &SymbolIndex,
428 scope: &ImportScope,
429 locals: &[LocalSymbol],
430 uref: &brink_ir::UnresolvedRef,
431) -> Option<DefinitionId> {
432 let path = &uref.path;
433
434 // Module-qualified (`-> barter::haggle`, issue #2287): resolved
435 // entirely separately from ink's own dotted `knot.stitch` addressing
436 // below — a module qualifier licenses reaching a top-level flow through
437 // an imported *module*, never a stitch/label path, and a miss here must
438 // not fall through to the single-segment resolution below (that
439 // fallback treating the *whole* qualified path as an opaque bare name
440 // is exactly how issue #2287's bug (b) let `-> haggle` slip through
441 // after the qualified form failed).
442 if uref.module_qualified {
443 return lookup_qualified_divert(index, scope, path);
444 }
445
446 // Dotted path — try exact qualified lookup, then qualify with current knot
447 if path.contains('.') {
448 if let Some(id) =
449 lookup_by_name(index, scope, path, &[SymbolKind::Stitch, SymbolKind::Label])
450 {
451 return Some(id);
452 }
453 // Try qualifying with current knot scope (e.g., `a_package.forest` → `adventure.a_package.forest`)
454 if let Some(knot) = &uref.scope.knot {
455 let qualified = format!("{knot}.{path}");
456 if let Some(id) = lookup_by_name(
457 index,
458 scope,
459 &qualified,
460 &[SymbolKind::Stitch, SymbolKind::Label],
461 ) {
462 return Some(id);
463 }
464 }
465 return None;
466 }
467
468 // Single segment — ink's hierarchical resolution:
469 // 1. Stitch or label in current knot
470 if let Some(knot) = &uref.scope.knot {
471 let qualified = format!("{knot}.{path}");
472 if let Some(id) = lookup_by_name(
473 index,
474 scope,
475 &qualified,
476 &[SymbolKind::Stitch, SymbolKind::Label],
477 ) {
478 return Some(id);
479 }
480 // Label in current stitch (knot.stitch.label)
481 if let Some(stitch) = &uref.scope.stitch
482 && let Some(id) = lookup_by_name(
483 index,
484 scope,
485 &format!("{knot}.{stitch}.{path}"),
486 &[SymbolKind::Label],
487 )
488 {
489 return Some(id);
490 }
491 }
492
493 // 2. Knot at top level — a *bare* divert name (no qualifier segment at
494 // all in source) must resolve only to a same-module candidate or one a
495 // symbol-level/glob `use` actually named (issue #2287): unlike the flat
496 // `lookup_by_name`/`classify` most other reference kinds use (which
497 // also treats a **qualified**-module import as licensing bare access,
498 // since e.g. a function-call callee may itself be spelled either way),
499 // a bare divert has no qualifier to license through — a candidate
500 // reachable only via a qualified-module import must stay unresolved
501 // here, or `-> haggle` would silently accept exactly the over-permissive
502 // reading #2287 reported as bug (b).
503 if let Some(id) = lookup_knot_bare(index, scope, path) {
504 return Some(id);
505 }
506
507 // 3. Top-level stitch (bare name, no parent knot). Issue #2298: same
508 // `is_qualified_import_only` exclusion as step 2, via the kind-generic
509 // [`lookup_bare_excluding_qualified_only`] — a stitch reachable only
510 // through a qualified-module import must not resolve a bare divert any
511 // more than a knot may. Currently unreachable for native (a `flow`
512 // always classifies `SymbolKind::Knot`, never `Stitch`, so no
513 // top-level-stitch candidate can carry a real cross-module `module` for
514 // this exclusion to fire against) — latent, not dead: fixed here
515 // alongside steps 5–6 so the whole post-knot tail obeys one rule
516 // instead of the Knot-only gate #2296 shipped, per issue #2298's scope.
517 if let Some(id) =
518 lookup_bare_excluding_qualified_only(index, scope, path, &[SymbolKind::Stitch])
519 {
520 return Some(id);
521 }
522
523 // 4. Label anywhere in current knot (search by suffix). Deliberately
524 // untouched by issue #2298: `knot` here is always `uref.scope.knot`,
525 // i.e. the *referring file's own* currently-open knot — a stitch or
526 // label found this way is always declared alongside it in the same
527 // file, hence the same declared module, hence `Candidacy::InScope`
528 // regardless of any import. There is no cross-module reading for
529 // `lookup_label_in_knot` to leak through, so the exclusion has nothing
530 // to guard here.
531 if let Some(knot) = &uref.scope.knot
532 && let Some(id) = lookup_label_in_knot(index, scope, knot, path)
533 {
534 return Some(id);
535 }
536
537 // 5. Top-level label — stored as bare name (visible from any scope).
538 // Issue #2298: same reasoning as step 3.
539 if let Some(id) = lookup_bare_excluding_qualified_only(index, scope, path, &[SymbolKind::Label])
540 {
541 return Some(id);
542 }
543
544 // 6. Variable divert target (`VAR x = -> knot`, then `-> x`). Issue
545 // #2298: same `is_qualified_import_only` exclusion as steps 2–3/5, via
546 // the shared kind-generic lookup — plus the `SymbolKind::Constant`
547 // omission recorded on issue #2083's thread (crediting that report):
548 // `resolve_function`'s own Variable-divert-target-shaped lookup (its
549 // "Try variables and constants" step) was widened to `[Variable,
550 // Constant]` by #2947 for the call-site gap #2083 reported, but this
551 // divert-target step was left `Variable`-only as a "next-wave
552 // candidate" — a `CONST x = -> knot` could never be diverted to via
553 // `-> x` even though the call-site twin already accepts a const-bound
554 // value. Fixed here in the same pass as the exclusion, not reordered
555 // to locals-first the way #2947 reordered `resolve_function`: step 7
556 // below already runs after this one, so — as for the `Variable` kind
557 // this step has always matched — a global `CONST x = -> knot` now wins
558 // over a same-named local param at a divert site, where before this
559 // widening the local won by falling through to step 7. That shadow
560 // order is deliberate and pinned by
561 // `global_constant_divert_target_shadows_a_same_named_local` below.
562 // Attribution: issue #2298's own body names only Stitch/Label/Variable
563 // at this step — the `Constant` addition is issue #2083's thread's
564 // next-wave candidate (its 2026-08-21 comment), taken in the same
565 // pass, not something #2298 asked for.
566 if let Some(id) = lookup_bare_excluding_qualified_only(
567 index,
568 scope,
569 path,
570 &[SymbolKind::Variable, SymbolKind::Constant],
571 ) {
572 return Some(id);
573 }
574
575 // 7. Divert parameter in scope (`=== knot(-> x) ===` then `-> x`)
576 lookup_local_in_scope(locals, path, &uref.scope)
577}
578
579/// Is `info` reachable *only* through a **qualified-module** import
580/// (`use ...::mod;` / `import mod;`, `scope.qualified_modules`) with no
581/// corresponding bare (symbol-level/glob) import of this exact name (issue
582/// #2287)? A qualified-module import licenses `module::name`/`module.name`
583/// access to any public export — [`classify`]'s `Candidacy::Imported`
584/// correctly grants that for reference kinds whose own syntax can carry a
585/// qualifier — but it must never *also* license the bare `name` spelling,
586/// which only a symbol-level or glob import brings into scope. `false` for
587/// a candidate with no module at all (the legacy/undeclared-module world,
588/// always bare-visible) and for one already bare-imported (both readings
589/// can hold at once per #1592's dual-reading ruling; the bare one still
590/// wins).
591fn is_qualified_import_only(scope: &ImportScope, info: &SymbolInfo) -> bool {
592 let Some(module) = &info.module else {
593 return false;
594 };
595 info.visibility == Visibility::Public
596 && scope.qualified_modules.contains(module)
597 && !scope
598 .bare_imports
599 .contains(&(module.clone(), info.name.clone()))
600}
601
602/// Bare (single-segment, no qualifier at all in source) top-level-`Knot`
603/// divert lookup (issue #2287). Deliberately duplicates
604/// [`lookup_by_name_direct`]'s own fast-path/tie-break structure — same
605/// std-reserved-root skip, same "`!multiple` sole match wins regardless of
606/// candidacy" byte-identity guarantee that lets a totally *unimported*
607/// cross-module `Knot` still resolve here exactly as before (its specific
608/// diagnostic — `E087` private-cross-module or `E025` import-required — is
609/// `modules::check`'s separate, more precise gate to raise on the
610/// resulting `ResolvedRef`, not this function's job to pre-empt) — with
611/// exactly one new exclusion, structurally parallel to the std skip: a
612/// candidate reachable *only* via a qualified-module import
613/// ([`is_qualified_import_only`]) is skipped before it can ever win, since
614/// a module import must never license the bare spelling (bug (b)). Not a
615/// thin wrapper over `lookup_by_name`/`classify` because that fast path
616/// applies its own exclusion **before** counting a candidate into
617/// `first_match`/`multiple`, exactly where this one must too — bolting the
618/// exclusion on afterward would still let a qualified-only candidate win
619/// as the "sole" match.
620///
621/// Issue #2298 widened this beyond diverts: `resolve_function`'s "try
622/// knots" step (ink allows a knot as a function via tunnels — the same
623/// divert-shaped addressing space, unlike a call resolving to an
624/// `External`/`Variable`/`Constant`, which stays on the ordinary
625/// `classify`-based rule per this module's top-of-file doc) reproduced bug
626/// (b) for a bare *call* `haggle()`, so it now shares this exact lookup
627/// rather than drifting its own copy. Thin wrapper over
628/// [`lookup_bare_excluding_qualified_only`], kept as its own named function
629/// (rather than inlining `&[SymbolKind::Knot]` at both call sites) because
630/// this name is what the doc comments elsewhere in this file point readers
631/// at.
632fn lookup_knot_bare(index: &SymbolIndex, scope: &ImportScope, name: &str) -> Option<DefinitionId> {
633 lookup_bare_excluding_qualified_only(index, scope, name, &[SymbolKind::Knot])
634}
635
636/// The kind-generic form of [`lookup_knot_bare`] (issue #2298): the same
637/// bare, qualified-import-only-excluding lookup, parameterized over the
638/// symbol-kind set so [`lookup_divert`]'s later steps (`Stitch`/`Label`/
639/// `Variable`+`Constant`) can share this one implementation too, instead of
640/// each hand-copying [`lookup_knot_bare_direct`]'s structure the way that
641/// function itself once hand-copied [`lookup_by_name_direct`]'s.
642fn lookup_bare_excluding_qualified_only(
643 index: &SymbolIndex,
644 scope: &ImportScope,
645 name: &str,
646 kinds: &[SymbolKind],
647) -> Option<DefinitionId> {
648 if let Some(id) = lookup_bare_excluding_qualified_only_direct(index, scope, name, kinds) {
649 return Some(id);
650 }
651 // Alias fallback (issue #1590), mirroring `lookup_by_name`'s own: an
652 // aliased bare-import item (`use ...::haggle as h;`) is never in
653 // `index.by_name` under its local alias, so the direct lookup above
654 // can never find it — `scope.aliases` is the indirection. The alias
655 // entry's mere presence already proves this file bare-imported that
656 // exact `(module, source_name)` pair (`ImportScope::new` only ever
657 // populates it from a bare import item that named an alias), so no
658 // further candidacy check is needed here, exactly as `lookup_by_name`
659 // performs none either.
660 let (module, source_name) = scope.aliases.get(name)?;
661 let ids = index.by_name.get(source_name.as_str())?;
662 ids.iter().find_map(|id| {
663 let info = index.symbols.get(id)?;
664 (kinds.contains(&info.kind) && info.module.as_deref() == Some(module.as_str()))
665 .then_some(*id)
666 })
667}
668
669/// The direct (non-alias) half of [`lookup_bare_excluding_qualified_only`] —
670/// see that function's own doc, and [`lookup_knot_bare`]'s original doc, for
671/// why this duplicates [`lookup_by_name_direct`]'s structure rather than
672/// reusing it.
673fn lookup_bare_excluding_qualified_only_direct(
674 index: &SymbolIndex,
675 scope: &ImportScope,
676 name: &str,
677 kinds: &[SymbolKind],
678) -> Option<DefinitionId> {
679 let ids = index.by_name.get(name)?;
680 let mut first_match = None;
681 let mut first_in_scope = None;
682 let mut first_imported = None;
683 let mut multiple = false;
684 for id in ids {
685 let Some(info) = index.symbols.get(id) else {
686 continue;
687 };
688 if !kinds.contains(&info.kind) {
689 continue;
690 }
691 let candidacy = classify(scope, info);
692 if candidacy == Candidacy::Other
693 && info.module.as_deref().is_some_and(is_reserved_root_module)
694 {
695 continue;
696 }
697 if is_qualified_import_only(scope, info) {
698 continue;
699 }
700 if first_match.is_none() {
701 first_match = Some(*id);
702 } else {
703 multiple = true;
704 }
705 match candidacy {
706 Candidacy::InScope if first_in_scope.is_none() => first_in_scope = Some(*id),
707 Candidacy::Imported if first_imported.is_none() => first_imported = Some(*id),
708 _ => {}
709 }
710 }
711 if !multiple {
712 return first_match;
713 }
714 first_in_scope.or(first_imported).or(first_match)
715}
716
717/// Does `module` (a candidate's real, fully `::`-joined declared module
718/// name) match a divert's own written qualifier segment(s) — the prefix of
719/// `-> qualifier::name` before the final `::`? Exact match covers the
720/// common single-level case (`barter` naming module `story::market::barter`
721/// end-to-end would require `qualifier == module`); the suffix form covers
722/// a qualifier that only spells the module's own trailing component(s),
723/// the shape `use story::market::barter;`'s dual-reading licenses (issue
724/// #1592) and issue #2287's own repro exercises.
725fn module_matches_qualifier(module: &str, qualifier: &str) -> bool {
726 module == qualifier || module.ends_with(&format!("::{qualifier}"))
727}
728
729/// Module-qualified divert lookup (`-> barter::haggle`, issue #2287):
730/// `path` is `uref.path` when `uref.module_qualified` is set —
731/// `::`-joined, never `.`-joined, so it splits cleanly into the qualifier
732/// prefix and the bare target name. Resolves only against a top-level
733/// `Knot` (the sole symbol kind a native `flow` divert target names) whose
734/// real declared module matches the qualifier
735/// ([`module_matches_qualifier`]) and which this file imported *qualified*
736/// (`scope.qualified_modules` — a symbol-level/glob import does not carry
737/// this licensing power for the qualified spelling, mirroring
738/// [`classify`]'s own module-qualified-import reading).
739fn lookup_qualified_divert(
740 index: &SymbolIndex,
741 scope: &ImportScope,
742 path: &str,
743) -> Option<DefinitionId> {
744 let (qualifier, name) = path.rsplit_once("::")?;
745 let ids = index.by_name.get(name)?;
746 ids.iter().find_map(|id| {
747 let info = index.symbols.get(id)?;
748 if info.kind != SymbolKind::Knot || info.visibility != Visibility::Public {
749 return None;
750 }
751 let module = info.module.as_deref()?;
752 if !module_matches_qualifier(module, qualifier) {
753 return None;
754 }
755 scope.qualified_modules.contains(module).then_some(*id)
756 })
757}
758
759fn resolve_variable(
760 index: &SymbolIndex,
761 scope: &ImportScope,
762 locals: &[LocalSymbol],
763 file_id: FileId,
764 uref: &brink_ir::UnresolvedRef,
765 map: &mut ResolutionMap,
766 diagnostics: &mut Vec<Diagnostic>,
767) {
768 let path = &uref.path;
769
770 match lookup_variable(index, scope, locals, uref) {
771 VarResult::Found(id) => {
772 map.push(ResolvedRef {
773 file: file_id,
774 range: uref.range,
775 target: id,
776 });
777 }
778 VarResult::Ambiguous => {
779 diagnostics.push(ambiguous_diag(file_id, uref.range, path));
780 }
781 VarResult::NotFound => {
782 // NS-A1 (`docs/stdlib-spec.md` §1.4): an otherwise-unresolved
783 // bare `none` is the brink-dialect Option absence literal —
784 // same "skip resolution, no diagnostic here" treatment as the
785 // T1b stdlib call names in `resolve_function`. Every user
786 // symbol interpretation above wins first (a LIST item, VAR,
787 // temp, … named `none` shadows the literal, E035-warned at its
788 // declaration); `strict-ink` rejection is the dialect gate's
789 // job, and the bare-`none`-needs-context declaration rule is
790 // E107 (`option_rules`).
791 if path == "none" {
792 return;
793 }
794
795 // Issue #2856 point 3: `is_builtin_function` is checked here —
796 // as a post-lookup fallback — not before `lookup_variable`
797 // runs, so a declared `VAR`/list item/knot/… of the same name
798 // (`manifest.rs`'s `E035` "name shadows a built-in function"
799 // warning already documents this as legal, shadowing behavior)
800 // wins resolution first. Only a genuinely unresolved reference
801 // to one of these classic uppercase ink intrinsics
802 // (`TURNS_SINCE`/`RANDOM`/…) falls through to this silent skip
803 // — mirroring `is_t1b_stdlib_name`'s already-correct ordering
804 // below in `resolve_function`. Before this fix the check ran
805 // unconditionally first, so a declared symbol could never
806 // shadow it: `VAR RANDOM = 42` / `{RANDOM}` silently dropped
807 // the reference (no resolution, no diagnostic) instead of
808 // reading 42 — reproduced end-to-end via `brink-cli`.
809 if is_builtin_function(path) {
810 return;
811 }
812 diagnostics.push(unresolved_diag(
813 index,
814 scope,
815 file_id,
816 uref.range,
817 path,
818 DiagnosticCode::E025,
819 &[],
820 ));
821 }
822 }
823}
824
825enum VarResult {
826 Found(DefinitionId),
827 Ambiguous,
828 NotFound,
829}
830
831/// Hierarchical variable lookup — returns the first match in priority order.
832fn lookup_variable(
833 index: &SymbolIndex,
834 scope: &ImportScope,
835 locals: &[LocalSymbol],
836 uref: &brink_ir::UnresolvedRef,
837) -> VarResult {
838 let path = &uref.path;
839
840 // 1. Locals (params/temps) in scope — they shadow globals
841 if let Some(id) = lookup_local_in_scope(locals, path, &uref.scope) {
842 return VarResult::Found(id);
843 }
844
845 // 2. Global variables / constants
846 if let Some(id) = lookup_by_name(
847 index,
848 scope,
849 path,
850 &[SymbolKind::Variable, SymbolKind::Constant],
851 ) {
852 return VarResult::Found(id);
853 }
854
855 // 3. List items by bare name
856 match lookup_list_item_bare(index, path) {
857 BareItemResult::Unique(id) => return VarResult::Found(id),
858 BareItemResult::Ambiguous => return VarResult::Ambiguous,
859 BareItemResult::NotFound => {}
860 }
861
862 // 4. Qualified list item (ListName.ItemName)
863 if path.contains('.')
864 && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::ListItem])
865 {
866 return VarResult::Found(id);
867 }
868
869 // 5. List names
870 if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List]) {
871 return VarResult::Found(id);
872 }
873
874 // 6. Knots and top-level stitches (visit counts)
875 if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Knot, SymbolKind::Stitch]) {
876 return VarResult::Found(id);
877 }
878
879 // 7. Stitches in current knot scope
880 if let Some(knot) = &uref.scope.knot
881 && let Some(id) = lookup_by_name(
882 index,
883 scope,
884 &format!("{knot}.{path}"),
885 &[SymbolKind::Stitch],
886 )
887 {
888 return VarResult::Found(id);
889 }
890
891 // 8. Qualified stitch/label (e.g. `knot.stitch` or `knot.stitch.label` visit count)
892 if path.contains('.') {
893 if let Some(id) =
894 lookup_by_name(index, scope, path, &[SymbolKind::Stitch, SymbolKind::Label])
895 {
896 return VarResult::Found(id);
897 }
898 // Try `knot.label` where label is stored as `knot.*.label` (label inside a stitch)
899 if let Some((knot, label)) = path.split_once('.')
900 && !label.contains('.')
901 && let Some(id) = lookup_label_in_knot(index, scope, knot, label)
902 {
903 return VarResult::Found(id);
904 }
905 }
906
907 // 9. Labels in current knot
908 if let Some(knot) = &uref.scope.knot
909 && let Some(id) = lookup_label_in_knot(index, scope, knot, path)
910 {
911 return VarResult::Found(id);
912 }
913
914 // 10. Labels at top level (no knot scope)
915 if uref.scope.knot.is_none()
916 && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Label])
917 {
918 return VarResult::Found(id);
919 }
920
921 // 11. TM-4b resolution fallback (docs/typed-mode-spec.md §6): every
922 // static dotted-path interpretation above (steps 3-10: list items,
923 // lists, knots/stitches, labels — "ink's static dotted paths... resolved
924 // first and win") has failed. If the path has more than one segment and
925 // its *head* segment alone resolves to a local (param/temp) or a global
926 // variable/constant, this is field access on that variable
927 // (`p.x`/`p.x.y`) rather than an unresolved static path — the resolved
928 // target is the head variable itself; the trailing segment(s) are field
929 // names carried structurally by the HIR `Path`, not by this resolution.
930 // Struct field-name validity (does `x` exist on `p`'s declared shape?) is
931 // a separate construction-time concern (`brink-analyzer::structs`), not
932 // resolution's. A single-segment path can never reach here having
933 // already failed steps 1-2 above (which already check locals/globals for
934 // the *whole* path), so this never fires for a bare variable reference.
935 if let Some((head, _rest)) = path.split_once('.') {
936 if let Some(id) = lookup_local_in_scope(locals, head, &uref.scope) {
937 return VarResult::Found(id);
938 }
939 if let Some(id) = lookup_by_name(
940 index,
941 scope,
942 head,
943 &[SymbolKind::Variable, SymbolKind::Constant],
944 ) {
945 return VarResult::Found(id);
946 }
947 }
948
949 VarResult::NotFound
950}
951
952/// Resolve a call-path reference (`RefKind::Function`).
953///
954/// **Range contract (issue #1561):** every `ResolvedRef` pushed below
955/// carries `range: uref.range` unchanged — never narrowed to a sub-segment
956/// (receiver-only, method-only). By construction (`brink_ir::symbols::
957/// project::Projector::walk_expr`'s `Expr::Call` arm) `uref.range` is
958/// already the callee `Path`'s own whole span, so this function's only
959/// obligation is to *not disturb it*. That whole-path range is the exact
960/// `(FileId, TextRange)` lookup key `lir::lower::expr::lower_call` and
961/// `ufcs_receiver_path`, `strict::check_void_root`, `coalesce`'s
962/// operand classifier, `ufcs::value_receiver_def`, and
963/// `infer::body::infer_call` all independently key on — see
964/// [`brink_ir::ResolvedRef::range`]'s doc for the full consumer list and
965/// the cross-layer regression test. A narrower range here (e.g. to support
966/// a rename edit) is a silent miscompile for all four; narrowing for a
967/// rename belongs at `brink-ide`'s own consumption layer instead
968/// (`ufcs_hover`'s segment-narrowing helpers are the established pattern —
969/// see #1550/#1554).
970fn resolve_function(
971 index: &SymbolIndex,
972 scope: &ImportScope,
973 locals: &[LocalSymbol],
974 file_id: FileId,
975 uref: &brink_ir::UnresolvedRef,
976 map: &mut ResolutionMap,
977 diagnostics: &mut Vec<Diagnostic>,
978) {
979 let path = &uref.path;
980
981 // Try externals first
982 if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::External]) {
983 map.push(ResolvedRef {
984 file: file_id,
985 range: uref.range,
986 target: id,
987 });
988 check_arity(index, file_id, uref, id, diagnostics);
989 return;
990 }
991
992 // Try knots (ink allows knots as functions via tunnels). Issue #2298:
993 // this is `lookup_knot_bare`, not the flat `lookup_by_name`/`classify`
994 // every other lookup in this function uses — a knot-as-tunnel-function
995 // call sits in the same divert-shaped addressing space `-> knot`
996 // itself does (`lookup_divert`'s step 2), so a bare call `haggle()`
997 // after only a module-qualified import (`use story::market::barter;`,
998 // no symbol-level import of `haggle`) must be rejected exactly like
999 // the bare divert `-> haggle` already is (issue #2287 bug (b)) —
1000 // before this fix, this step's flat lookup let `Candidacy::Imported`
1001 // from the qualified-module reading win regardless, reproducing bug
1002 // (b) for calls instead of diverts. The `External`/`List`/
1003 // `Variable`+`Constant` lookups below stay on the ordinary
1004 // `classify`-based rule deliberately: they are not divert-shaped, and
1005 // this module's top-of-file doc's "calls, struct literals, etc." carve-
1006 // out still holds for them — only a knot's own tunnel-call addressing
1007 // is special-cased here.
1008 if let Some(id) = lookup_knot_bare(index, scope, path) {
1009 map.push(ResolvedRef {
1010 file: file_id,
1011 range: uref.range,
1012 target: id,
1013 });
1014 check_arity(index, file_id, uref, id, diagnostics);
1015 return;
1016 }
1017
1018 // A real **call site** (`arg_count.is_some()`) naming a reserved builtin
1019 // (`is_builtin_function`'s classic uppercase intrinsics or
1020 // `is_t1b_stdlib_name`'s T1b verbs) must not let a `List`/`Variable`
1021 // symbol of that same name claim the call below — issue #2856 review:
1022 // `VAR MAX = 10` + `{MAX(1, 2)}` compiled clean and then died at
1023 // runtime with `RuntimeError::NotCallable("int")`, because the
1024 // unconditional `SymbolKind::Variable` lookup just below had no
1025 // call-site guard, unlike the list-item bare-name gate a few lines down
1026 // (`arg_count.is_none()`, issue #2830) that this mirrors. A `VAR`/`LIST`
1027 // is not itself callable the way a knot or a variable holding a divert
1028 // target is — routing a reserved-name call through to the real builtin
1029 // fallback below (`recognize_builtin`/`lower_t1b_stdlib_call`) is a
1030 // clean compile with well-defined behavior, exactly matching this same
1031 // name's pre-#2856 behavior, instead of a `CallVariable`/`ListFromInt`
1032 // emitted against a value that was never meant to be called. Read-site
1033 // shadowing (`resolve_variable`'s bare `{MAX}`) is untouched — this
1034 // guard only narrows the *callee* lookup at a call site.
1035 let reserved_call_site =
1036 uref.arg_count.is_some() && (is_builtin_function(path) || is_t1b_stdlib_name(path));
1037
1038 // Try list names (ink allows `list(n)` as type conversion)
1039 if !reserved_call_site && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List])
1040 {
1041 map.push(ResolvedRef {
1042 file: file_id,
1043 range: uref.range,
1044 target: id,
1045 });
1046 return;
1047 }
1048
1049 // Try locals (temps/params used as function names, e.g. `{storyletFunction(args)}`)
1050 // — checked BEFORE the global variable/constant lookup below, so a local
1051 // (param/temp/`let`) shadows a same-named global at a call site exactly
1052 // as it does at a bare-read site (`lookup_variable`'s step 1) and at a
1053 // dotted callee's head (the B3a arm below:
1054 // `lookup_local_in_scope(..).or_else(|| lookup_by_name(.., [Variable,
1055 // Constant]))`). Before #2083's fix this arm ran *after* the global
1056 // lookup, so a global could shadow a local at a call site while a bare
1057 // read of the same name resolved the local — one name calling one symbol
1058 // and reading another.
1059 //
1060 // ⚠ issue #2867 (open, unruled): unlike the `List` arm above and the
1061 // `Variable`/`Constant` arm below, this lookup is NOT covered by
1062 // `reserved_call_site`. Per
1063 // `push_local`'s own call sites (`brink_ir::symbols::project`),
1064 // `lookup_local_in_scope` can only ever return a `LocalSymbol` of kind
1065 // `SymbolKind::Param` (knot/stitch/function params) or
1066 // `SymbolKind::Temp` (`~ temp`, `for`-loop bindings, lambda params) —
1067 // that is the complete enumeration of kinds this arm can claim a call
1068 // site with. Neither kind is gated: both unconditionally claim this
1069 // arm today. `~ temp MAX = 10` + `{MAX(1, 2)}` under strict-ink compiles clean
1070 // (no `E035`, no `E183`) and then faults at runtime with
1071 // `RuntimeError::NotCallable("int")` — no diagnostic of any kind,
1072 // unlike the sibling `VAR`/`List` cases, which fall through to the
1073 // real builtin instead. Pinned (not yet fixed) by
1074 // `crates/brink-compiler/tests/issue_2856_builtin_shadow.rs`'s
1075 // `local_named_builtin_faults_at_runtime_not_callable` (temp) and
1076 // `param_named_builtin_faults_at_runtime_not_callable` (param). Fixing
1077 // this is a maintainer ruling (issue #2867's "Ask"): gate locals like
1078 // `VAR`/`List` so the call falls through to the real builtin, or
1079 // refuse with a compile diagnostic naming the local and its type —
1080 // deliberately not decided here.
1081 if let Some(id) = lookup_local_in_scope(locals, path, &uref.scope) {
1082 map.push(ResolvedRef {
1083 file: file_id,
1084 range: uref.range,
1085 target: id,
1086 });
1087 return;
1088 }
1089
1090 // Try variables and constants (ink allows calling a variable holding a
1091 // function ref; the native surface additionally allows a `const` to hold
1092 // one — #1862's bare-name form and #1774's lambda-literal decl default,
1093 // docs/t1c-spec.md §2a). `resolve_variable`'s own bare-read lookup
1094 // (`lookup_variable` above) searches `[Variable, Constant]` together,
1095 // with locals shadowing both (its step 1) — this call-site lookup now
1096 // mirrors both halves of that shape: the same kind list, in the same
1097 // locals-first order (see the arm above). It had been left
1098 // `Variable`-only, so a CONST-bound fn value's call site could never
1099 // resolve here — issue #2083's root cause. Root-caused to this one-line
1100 // gap in `brink-analyzer` itself (confirmed to reproduce identically via
1101 // a direct `brink_analyzer::resolve`/`analyze()` call, with no
1102 // `brink-db` involved at all — the issue's own suspicion that this was
1103 // an incremental-resolution bug in `brink-db`'s `resolve_query` was a
1104 // misdiagnosis: the brink-ir test cited as clean-path evidence never
1105 // actually asserted on `analyze()`'s resolution diagnostics, only on
1106 // the declaring global's own lowered default shape).
1107 if !reserved_call_site
1108 && let Some(id) = lookup_by_name(
1109 index,
1110 scope,
1111 path,
1112 &[SymbolKind::Variable, SymbolKind::Constant],
1113 )
1114 {
1115 map.push(ResolvedRef {
1116 file: file_id,
1117 range: uref.range,
1118 target: id,
1119 });
1120 return;
1121 }
1122
1123 // Bare list item name colliding with a stdlib verb (issue #2830): a
1124 // `RefKind::Function` reference whose path names a real declared list
1125 // item (e.g. `pop`, when some `LIST` declares an item literally called
1126 // `pop`) must resolve to that item — an author-declared symbol always
1127 // shadows a same-named stdlib builtin, exactly as `resolve_variable`'s
1128 // `lookup_variable` step 3 already does for `RefKind::Variable` refs.
1129 // Without this step, `is_t1b_stdlib_name` below silently claims the
1130 // name first (it has no way to know a real symbol exists) and the ref
1131 // is dropped with neither a resolution nor a diagnostic — the
1132 // `completeness` proptest counterexample this fixes.
1133 //
1134 // `arg_count.is_none()` restricts this to a `#fn(target)` literal site
1135 // (project_manifest's documented distinction — `#fn` binds a *prefix*
1136 // of the param row, so it never carries a call arity), the same
1137 // discriminator the UFCS branch below already uses. A real **call
1138 // site** (`arg_count.is_some()`, e.g. `push(arr, 5)`) must keep falling
1139 // through to `is_t1b_stdlib_name` and the t1b stdlib call lowering —
1140 // resolving it to the list item here would make a stdlib call compile
1141 // clean and then fault at runtime with `UnresolvedDefinition` when LIR
1142 // lowering looks it up as a stdlib verb instead of a list item.
1143 if uref.arg_count.is_none() {
1144 match lookup_list_item_bare(index, path) {
1145 BareItemResult::Unique(id) => {
1146 map.push(ResolvedRef {
1147 file: file_id,
1148 range: uref.range,
1149 target: id,
1150 });
1151 return;
1152 }
1153 BareItemResult::Ambiguous => {
1154 diagnostics.push(ambiguous_diag(file_id, uref.range, path));
1155 return;
1156 }
1157 BareItemResult::NotFound => {}
1158 }
1159 }
1160
1161 // T1b stdlib slice 1 (docs/t1b-surface-spec.md §5): `len`/`keys`/
1162 // `values`/`contains`/`push`/`insert`/`remove`/`remove_at` with no
1163 // matching user symbol are the brink-dialect builtins, handled at LIR
1164 // lowering —
1165 // same "skip resolution, no diagnostic here" treatment as
1166 // `is_builtin_function` below. Dialect-agnostic at this layer (an
1167 // author-defined symbol of the same name always wins regardless of
1168 // dialect, matched by the lookups above before this is reached);
1169 // `strict-ink` rejection of an unresolved use is a separate diagnostic
1170 // (`brink-analyzer::dialect_gate`, which — unlike this resolution pass —
1171 // does know the dialect).
1172 //
1173 // Issue #2856 point 3: `is_builtin_function` (the classic uppercase ink
1174 // intrinsics — `TURNS_SINCE`/`RANDOM`/…) is checked here too, alongside
1175 // `is_t1b_stdlib_name`, rather than unconditionally before every lookup
1176 // above as it was before this fix. `manifest.rs`'s `E035` ("name
1177 // shadows a built-in function") already documents both name sets as
1178 // author-shadowable with a warning, worded identically for each — but
1179 // only `is_t1b_stdlib_name` actually honored that here; the
1180 // `is_builtin_function` check ran first and unconditionally, so a
1181 // declared external/knot/list/variable/local of the same name was
1182 // never consulted at a *call* site either. Confirmed end-to-end via
1183 // `brink-cli`: without this fix a real silent drop occurred, not merely
1184 // a proptest-generator gap (the generator only ever emits lowercase
1185 // identifiers, so it could never reach `is_builtin_function`'s
1186 // all-uppercase names to begin with — see `arb_ident` in
1187 // `proptest_resolve.rs`).
1188 if is_t1b_stdlib_name(path) || is_builtin_function(path) {
1189 return;
1190 }
1191
1192 // B3a UFCS-shaped callee (issue #1482, D1–D5 RULED 2026-07-26): every
1193 // static dotted-path interpretation above has failed, but the path's
1194 // *head* segment alone names a value in scope — this is method-call
1195 // syntax on that value (`g.greet(3)`), not an unresolved static path.
1196 // Exactly the TM-4b fallback `lookup_variable`'s step 11 already applies
1197 // to a dotted *value* reference (`p.x.y`), applied to the callee
1198 // position: the resolved target is the head value itself, and the
1199 // trailing segment is carried structurally by the HIR `Path`.
1200 //
1201 // Which of the two meanings that trailing segment has (a callable field
1202 // of the receiver's type, or a free function to desugar onto) is a
1203 // *type-directed* question this resolution pass cannot answer — so it
1204 // resolves the receiver and stays silent, and `brink-analyzer::ufcs`
1205 // owns the verdict and every diagnostic for the site (`E140`–`E143`).
1206 // Suppressing `E025` here is what keeps a legal method call
1207 // diagnostic-free and an illegal one from being reported twice.
1208 //
1209 // Inert for the ink corpus by construction: ink's own `FunctionCall`
1210 // lowering always builds a single-segment callee path, so no ink source
1211 // can reach this branch (see `ufcs`' module doc).
1212 //
1213 // `arg_count.is_some()` narrows this to a real **call site**. A
1214 // `RefKind::Function` reference is also recorded for a `#fn(target)`
1215 // literal's target, and that one always carries `arg_count: None`
1216 // (`project_manifest`'s own documented distinction — `#fn` binds a
1217 // *prefix* of the param row, so it has no call arity). A dotted `#fn`
1218 // target is not method-call syntax and has no UFCS verdict; it must
1219 // keep failing as an unresolved reference here rather than silently
1220 // resolving to its head value.
1221 if uref.arg_count.is_some()
1222 && let Some((head, _rest)) = path.split_once('.')
1223 && let Some(id) = lookup_local_in_scope(locals, head, &uref.scope).or_else(|| {
1224 lookup_by_name(
1225 index,
1226 scope,
1227 head,
1228 &[SymbolKind::Variable, SymbolKind::Constant],
1229 )
1230 })
1231 {
1232 map.push(ResolvedRef {
1233 file: file_id,
1234 range: uref.range,
1235 target: id,
1236 });
1237 return;
1238 }
1239
1240 diagnostics.push(unresolved_diag(
1241 index,
1242 scope,
1243 file_id,
1244 uref.range,
1245 path,
1246 DiagnosticCode::E025,
1247 &[SymbolKind::Knot],
1248 ));
1249}
1250
1251/// Check that the number of arguments at the call site matches the target's parameter count.
1252fn check_arity(
1253 index: &SymbolIndex,
1254 file_id: FileId,
1255 uref: &brink_ir::UnresolvedRef,
1256 target: DefinitionId,
1257 diagnostics: &mut Vec<Diagnostic>,
1258) {
1259 let Some(call_arg_count) = uref.arg_count else {
1260 return;
1261 };
1262 let Some(info) = index.symbols.get(&target) else {
1263 return;
1264 };
1265 let expected = info.params.len();
1266 if call_arg_count != expected {
1267 diagnostics.push(Diagnostic {
1268 file: file_id,
1269 range: uref.range,
1270 message: format!(
1271 "{}: `{}` expects {} argument(s), got {}",
1272 DiagnosticCode::E031.title(),
1273 uref.path,
1274 expected,
1275 call_arg_count,
1276 ),
1277 code: DiagnosticCode::E031,
1278 });
1279 }
1280}
1281
1282fn resolve_list_ref(
1283 index: &SymbolIndex,
1284 scope: &ImportScope,
1285 file_id: FileId,
1286 uref: &brink_ir::UnresolvedRef,
1287 map: &mut ResolutionMap,
1288 diagnostics: &mut Vec<Diagnostic>,
1289) {
1290 let path = &uref.path;
1291
1292 // Try qualified list item (ListName.ItemName)
1293 if path.contains('.')
1294 && let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::ListItem])
1295 {
1296 map.push(ResolvedRef {
1297 file: file_id,
1298 range: uref.range,
1299 target: id,
1300 });
1301 return;
1302 }
1303
1304 // Try bare list item name
1305 match lookup_list_item_bare(index, path) {
1306 BareItemResult::Unique(id) => {
1307 map.push(ResolvedRef {
1308 file: file_id,
1309 range: uref.range,
1310 target: id,
1311 });
1312 return;
1313 }
1314 BareItemResult::Ambiguous => {
1315 diagnostics.push(ambiguous_diag(file_id, uref.range, path));
1316 return;
1317 }
1318 BareItemResult::NotFound => {}
1319 }
1320
1321 // Try list name
1322 if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::List]) {
1323 map.push(ResolvedRef {
1324 file: file_id,
1325 range: uref.range,
1326 target: id,
1327 });
1328 return;
1329 }
1330
1331 diagnostics.push(unresolved_diag(
1332 index,
1333 scope,
1334 file_id,
1335 uref.range,
1336 path,
1337 DiagnosticCode::E025,
1338 &[],
1339 ));
1340}
1341
1342/// Resolve a struct construction literal's leading shape name (`Name#{…}`,
1343/// TM-4b, docs/typed-mode-spec.md §6) against declared `SymbolKind::Struct`
1344/// symbols. Always a bare (undotted) name — the construction-literal grammar
1345/// only ever puts a single identifier before `#{` — so this is a direct
1346/// by-name lookup, no hierarchical/qualified fallback chain like diverts or
1347/// variables need.
1348fn resolve_struct_ref(
1349 index: &SymbolIndex,
1350 scope: &ImportScope,
1351 file_id: FileId,
1352 uref: &brink_ir::UnresolvedRef,
1353 map: &mut ResolutionMap,
1354 diagnostics: &mut Vec<Diagnostic>,
1355) {
1356 let path = &uref.path;
1357 if let Some(id) = lookup_by_name(index, scope, path, &[SymbolKind::Struct]) {
1358 map.push(ResolvedRef {
1359 file: file_id,
1360 range: uref.range,
1361 target: id,
1362 });
1363 return;
1364 }
1365 diagnostics.push(unresolved_diag(
1366 index,
1367 scope,
1368 file_id,
1369 uref.range,
1370 path,
1371 DiagnosticCode::E068,
1372 &[],
1373 ));
1374}
1375
1376/// Resolve a TM-2 type annotation's bare nominal leaf name (issue #2249) —
1377/// a struct field's declared type, or a `VAR`/`CONST`/`temp` annotation —
1378/// against declared `SymbolKind::Struct` symbols, exactly like
1379/// [`resolve_struct_ref`]'s lookup.
1380///
1381/// **Deliberately no diagnostic on a miss**, unlike `resolve_struct_ref`'s
1382/// `E068`: a `RefKind::Type` reference's `path` is not guaranteed to name a
1383/// struct at all — `int`, `float`, `List`, … are equally legal `Named`
1384/// leaves (`brink_ir::TypeExpr::Named`'s own doc), so "no declared struct
1385/// named this" is the overwhelmingly common, entirely legal case, not an
1386/// error. `annotations::check` (`E061`) is the dedicated "is this a
1387/// recognized type at all" diagnostic, run separately over the same HIR —
1388/// this function only ever *feeds* lowering's struct-shape chase
1389/// (`lir::lower::structs::record_global_annotation`,
1390/// `lir::lower::context::record_temp_annotation`) a resolved identity when
1391/// one exists, mirroring those two callers' own prior silent-`None`
1392/// posture (`ShapeTable::resolve` before this issue).
1393///
1394/// This *is* still narrower than `E061`'s own vocabulary check
1395/// (`annotations::declared_struct_names`): that lookup is project-flat,
1396/// with no referrer-scoping or std-exclusion at all, so a std-only struct
1397/// name is "recognized" for `E061`'s purposes regardless of importer —
1398/// unlike this function's `lookup_by_name`, which *does* exclude an
1399/// unimported std candidate. A `~ temp c: Cue` naming only a mounted std
1400/// module's `Cue`, with no project-side homonym or import, therefore still
1401/// raises no diagnostic anywhere today: `E061` accepts it (the name is
1402/// declared *somewhere*), and this resolution silently misses (by design,
1403/// per the paragraph above) rather than raising a new one. Issue #2249
1404/// leaves that specific compounding gap unruled — `annotations::TypeNames`
1405/// becoming referrer-scoped would be the natural fix, tracked there.
1406fn resolve_type_ref(
1407 index: &SymbolIndex,
1408 scope: &ImportScope,
1409 file_id: FileId,
1410 uref: &brink_ir::UnresolvedRef,
1411 map: &mut ResolutionMap,
1412) {
1413 if let Some(id) = lookup_by_name(index, scope, &uref.path, &[SymbolKind::Struct]) {
1414 map.push(ResolvedRef {
1415 file: file_id,
1416 range: uref.range,
1417 target: id,
1418 });
1419 }
1420}
1421
1422// ─── Lookup helpers ─────────────────────────────────────────────────
1423
1424/// Look up a local variable (param or temp) by bare name within the given
1425/// scope, among *this file's own* locals (issue #517 — locals never resolve
1426/// across files, since a knot's body lives in exactly one file).
1427///
1428/// A local matches if its name equals the bare name AND its scope is compatible:
1429/// same knot, and either same stitch or a knot-level param (stitch=None) which
1430/// is visible in all stitches. When multiple candidates match (e.g. a param and
1431/// a temp with the same name), picks the closest-preceding declaration.
1432fn lookup_local_in_scope(
1433 locals: &[LocalSymbol],
1434 bare_name: &str,
1435 scope: &Scope,
1436) -> Option<DefinitionId> {
1437 let mut best: Option<&LocalSymbol> = None;
1438
1439 for local in locals {
1440 if local.name != bare_name {
1441 continue;
1442 }
1443 // Knot must match
1444 if local.scope.knot != scope.knot {
1445 continue;
1446 }
1447 // A knot-level local (stitch=None) is visible in all stitches.
1448 // A stitch-level local is only visible in that stitch.
1449 if local.scope.stitch.is_some() && local.scope.stitch != scope.stitch {
1450 continue;
1451 }
1452 // Pick closest-preceding by range start
1453 match best {
1454 Some(prev) if local.range.start() > prev.range.start() => {
1455 best = Some(local);
1456 }
1457 None => {
1458 best = Some(local);
1459 }
1460 _ => {}
1461 }
1462 }
1463
1464 best.map(|local| local_definition_id(&local.scope, &local.name, local.kind))
1465}
1466
1467/// Ink built-in functions that are resolved at LIR lowering, not by the
1468/// symbol index.
1469///
1470/// Delegates to `brink_ir::lir::is_builtin_function` (issue #2863) rather
1471/// than hand-keeping its own copy of the 22-name list: this crate already
1472/// depends on `brink-ir` (the edge only runs one way — `brink-ir` cannot
1473/// depend back on `brink-analyzer`), so `brink-ir` is where the canonical
1474/// list lives, alongside the `recognize_builtin` table it must agree with
1475/// by construction. Before this fix, the two crates hand-kept two
1476/// independent copies of this same list — the exact drift risk that PR
1477/// #2859's review flagged: the lists agreed on *content* (22 names each)
1478/// and a resolution-*order* bug still reached production, because content
1479/// equality alone doesn't prove the two call sites consult the list at the
1480/// same point in resolution. A single shared implementation removes the
1481/// "one copy edited, the other forgotten" half of that risk; the *order*
1482/// half is a separate, per-call-site invariant, pinned end-to-end by
1483/// `crates/brink-compiler/tests/issue_2856_builtin_shadow.rs` and by this
1484/// crate's own `crates/internal/brink-analyzer/tests/
1485/// reserved_names_cross_crate.rs`.
1486///
1487/// `pub` rather than `pub(crate)` (module `resolve` itself stays private) so
1488/// `lib.rs`'s `#[doc(hidden)] pub mod test_support` can re-export it —
1489/// issue #2856: the `completeness` proptest
1490/// (`tests/proptest_resolve.rs`) needs the REAL name set this function
1491/// checks, not a hand-duplicated copy.
1492pub fn is_builtin_function(name: &str) -> bool {
1493 brink_ir::lir::is_builtin_function(name)
1494}
1495
1496/// T1b stdlib slice 1 function names (`docs/t1b-surface-spec.md` §5) plus
1497/// the TM-3-completion pure conversion intrinsics `int`/`float`/`string`
1498/// (`docs/typed-mode-spec.md` §4, maintainer ruling 2026-07-13, issue #659,
1499/// "per the stdlib slice-1 pattern"). Lowercase free functions,
1500/// brink-dialect-gated.
1501///
1502/// Delegates to `brink_ir::lir::is_t1b_stdlib_name` (issue #2863) for the
1503/// same reason as [`is_builtin_function`] just above — this used to be a
1504/// second hand-kept copy of `brink_ir`'s LIR-lowering list
1505/// (`lir::lower::expr::is_t1b_stdlib_name`); now there is exactly one
1506/// place this list is spelled out, and this function is a thin delegate to
1507/// it.
1508///
1509/// `resolve_function`'s lookup chain (externals, knots, lists, variables,
1510/// locals) always runs first, so an author-defined symbol of the same name
1511/// resolves normally — shadowing the builtin (§5's ruling) — and only a
1512/// resolution *failure* additionally checks this list before falling back
1513/// to the builtin (silently, no diagnostic) instead of emitting E025.
1514/// `is_builtin_function` now honors the identical ordering (issue #2856
1515/// point 3 — before that fix it was checked unconditionally, first, so a
1516/// declared symbol could never shadow one of *those* names; see that
1517/// function's own doc for the confirmed silent-drop this caused).
1518///
1519/// `pub` rather than `pub(crate)` for the same `test_support` re-export
1520/// reason as `is_builtin_function` above.
1521pub fn is_t1b_stdlib_name(name: &str) -> bool {
1522 brink_ir::lir::is_t1b_stdlib_name(name)
1523}
1524
1525/// `pub(crate)`: reused by `signature.rs` (issue #712) to resolve a `#fn`
1526/// creation-site target to its declaring knot when computing a VAR/CONST
1527/// global's declaration-derived `fn(T…): R` type — the same "function knot
1528/// by bare name" lookup [`resolve_function`] itself does first, without
1529/// needing this pass's locals/scope machinery (a `#fn` target at global-
1530/// initializer position has no enclosing body to scope against).
1531///
1532/// Alias ruling (issue #1590): `use mod::name as alias;` / `IMPORT { name AS
1533/// alias } FROM mod` binds `alias` to `name`'s target *in addition to* `name`
1534/// itself — not instead of it. The alias never shadows or revokes the source
1535/// spelling's own bare visibility (still decided by [`classify`] against
1536/// `scope.bare_imports`, unchanged by this fallback). This is a deliberate
1537/// departure from Rust's `use … as` (which drops the original binding):
1538/// [`lookup_by_name`]'s "byte-identity guarantee" fast path already returns
1539/// a **globally unique** name unconditionally, ignoring `ImportScope`
1540/// entirely — so a strict revoke-on-alias rule would only ever bite in the
1541/// rarer ambiguous-candidate case, silently keeping the source name resolvable
1542/// everywhere else. Rather than ship a rule that only sometimes holds, `AS`
1543/// stays purely additive: predictable in every case, in both dialects.
1544///
1545/// **Precedence when an alias collides with an in-scope direct name**:
1546/// [`lookup_by_name_direct`] always runs first, and this function only
1547/// consults `scope.aliases` when that direct lookup comes up empty. So if
1548/// `IMPORT { haggle AS start } FROM quest` is written in a file that also
1549/// defines a knot named `start`, every bare reference to `start` resolves to
1550/// the *local* `start` knot — the direct match wins silently, and the alias
1551/// is unreachable under that name. Nothing currently diagnoses this
1552/// collision (`E089` only dedupes among import items; it never checks
1553/// against file-local definitions); a shadowing diagnostic is tracked as a
1554/// follow-up rather than blocking this fix.
1555pub(crate) fn lookup_by_name(
1556 index: &SymbolIndex,
1557 scope: &ImportScope,
1558 name: &str,
1559 kinds: &[SymbolKind],
1560) -> Option<DefinitionId> {
1561 if let Some(id) = lookup_by_name_direct(index, scope, name, kinds) {
1562 return Some(id);
1563 }
1564
1565 // Alias fallback: `index.by_name` is keyed by definitions' own spellings
1566 // only, so a bare import's local alias — bound nowhere else — is never
1567 // found by the direct lookup above. Resolve it explicitly against the
1568 // specific `(module, source_name)` the import named; `kinds` and the
1569 // module still gate the match so an alias can never reach into the wrong
1570 // module or the wrong symbol kind.
1571 let (module, source_name) = scope.aliases.get(name)?;
1572 let ids = index.by_name.get(source_name.as_str())?;
1573 ids.iter().find_map(|id| {
1574 let info = index.symbols.get(id)?;
1575 (kinds.contains(&info.kind) && info.module.as_deref() == Some(module.as_str()))
1576 .then_some(*id)
1577 })
1578}
1579
1580/// The direct (non-alias) name lookup — everything [`lookup_by_name`] did
1581/// before issue #1590's alias fallback, plus the 2026-08-03 SUBTRACTION
1582/// RULING's std-invisibility gate (issue #2197, doc below) — no longer
1583/// byte-identical to that description, but byte-identical for every corpus
1584/// that never coexists with a `std::…` candidate (the whole
1585/// pre-stdlib-mount world).
1586fn lookup_by_name_direct(
1587 index: &SymbolIndex,
1588 scope: &ImportScope,
1589 name: &str,
1590 kinds: &[SymbolKind],
1591) -> Option<DefinitionId> {
1592 let ids = index.by_name.get(name)?;
1593
1594 let mut first_match: Option<DefinitionId> = None;
1595 let mut first_in_scope: Option<DefinitionId> = None;
1596 let mut first_imported: Option<DefinitionId> = None;
1597 let mut multiple = false;
1598
1599 for id in ids {
1600 let Some(info) = index.symbols.get(id) else {
1601 continue;
1602 };
1603 if !kinds.contains(&info.kind) {
1604 continue;
1605 }
1606 let candidacy = classify(scope, info);
1607 // Issue #2197, per #2080's SCOPE FENCE (`docs/decision-log.md`,
1608 // "Stdlib mounts into `Environment`'s manifest at the producer, as
1609 // plain source"): the mount puts std source into every project's
1610 // manifest, but "nothing in it is marked `pub` and no confinement
1611 // rule scopes what a project's own `use` may reach into it" — a
1612 // real `use std::…` still needs #1582's `pub` marker and #2167's
1613 // confinement, neither built yet. Until then, stdlib symbols are
1614 // reachable only via that not-yet-existing explicit import — there
1615 // is no implicit inclusion, so `classify` can never answer
1616 // `Imported` for a std candidate today (nothing under `std`
1617 // can be marked public yet) — every std candidate this file does
1618 // not itself
1619 // belong to (i.e. not `InScope`, which still covers a std file
1620 // referencing its own std-declared siblings) is `Other`. Skip it
1621 // entirely here, *before* it is counted into `first_match`/
1622 // `multiple` below: an `Other`-classified std candidate must never
1623 // win the flat-fallback tie-break a few lines down, which would
1624 // otherwise let a project silently resolve into the mounted
1625 // preset with no import at all — including when it is the *sole*
1626 // match, where the `!multiple` fast path below would otherwise
1627 // return it unconditionally. This interacts with M-2d's own
1628 // coexistence machinery (`is_cross_declared_module_collision`,
1629 // which is what lets a std candidate coexist in `by_name` in the
1630 // first place) by narrowing exactly one of its three resolution
1631 // tiers — `Other` — for the std case only; `InScope` and
1632 // `Imported` are untouched, so a std file's own internal
1633 // references, and a future real `use std::…` import once #1582/
1634 // #2167 ship, keep resolving normally.
1635 //
1636 // #2251: this exclusion is not actually std-specific — it applies
1637 // to "any reserved-root candidate this file does not itself
1638 // belong to", so it now checks `is_reserved_root_module` against
1639 // the whole `RESERVED_ROOTS` set rather than the single `std`
1640 // literal `is_std_module` used to compare against. Behavior is
1641 // unchanged today (the set has exactly one member), and a future
1642 // second mounted library gets this same exclusion for free.
1643 if candidacy == Candidacy::Other
1644 && info.module.as_deref().is_some_and(is_reserved_root_module)
1645 {
1646 continue;
1647 }
1648 if first_match.is_none() {
1649 first_match = Some(*id);
1650 } else {
1651 multiple = true;
1652 }
1653 match candidacy {
1654 Candidacy::InScope if first_in_scope.is_none() => first_in_scope = Some(*id),
1655 Candidacy::Imported if first_imported.is_none() => first_imported = Some(*id),
1656 _ => {}
1657 }
1658 }
1659
1660 // Fast path (byte-identity guarantee): with zero or one *non-std*
1661 // candidate of the requested kind — the entire strict-ink and
1662 // single-module world — the sole match is returned exactly as the
1663 // pre-M-2d flat lookup did, so the import scope never changes an
1664 // existing corpus's resolution. This is no longer quite "zero or one
1665 // candidate of the requested kind" (2026-08-03, issue #2197): a std
1666 // candidate that classifies `Other` is skipped above *before* it ever
1667 // reaches `first_match`/`multiple`, so a name with exactly one ordinary
1668 // candidate plus any number of coexisting std ones still takes this
1669 // fast path — and a name with std candidates *only* returns `None`
1670 // here, not the std candidate, unlike the byte-identical pre-#2197
1671 // description this comment used to give.
1672 if !multiple {
1673 return first_match;
1674 }
1675
1676 // Multiple cross-module candidates (only reachable now that the #784/#793
1677 // stopgap is relaxed and same-name public defs coexist): the referrer's
1678 // own-module / legacy candidate wins, else an imported public one, else
1679 // fall back to the flat first-winner — which keeps `modules::check`'s
1680 // E025 import-required diagnostic (keyed off the resolved target) firing
1681 // for a genuinely un-imported cross-module reference, exactly as before.
1682 first_in_scope.or(first_imported).or(first_match)
1683}
1684
1685/// The **scope-free** subset of [`lookup_by_name`]: the sole definition of
1686/// `name` whose kind is in `kinds`, or `None` when there is no such
1687/// definition **or more than one**.
1688///
1689/// This exists for callers that have no full [`ImportScope`] to hand — issue
1690/// #1909's UFCS-result typing runs inside `infer::body`, whose [`BodyCtx`]
1691/// (`brink-db`'s narrowed per-def HIR projection never holds a whole
1692/// `HirFile`) carries no file imports at all. `referrer_module` is the one
1693/// piece of that missing scope this function *can* still be handed — the
1694/// referring def's own declared module (issue #2233; `BodyCtx::
1695/// referrer_module`), the same value `ImportScope::file_module` would carry.
1696///
1697/// **It is a strict subset of what [`lookup_by_name`] answers, never a
1698/// second resolution rule** (issue #2216, unifying it with the
1699/// std-invisibility gate [`lookup_by_name_direct`] added for the scoped path
1700/// — 2026-08-03, issue #2197). This function still has no full
1701/// [`ImportScope`] to consult, so it cannot classify a candidate `Imported`
1702/// vs cross-module `Other` the way [`classify`] does — a candidate declared
1703/// in a mounted `std…` module is excluded here exactly as [`lookup_by_name`]
1704/// excludes it under the default (no-import) scope, **unless** it is
1705/// declared in the referrer's own module (`referrer_module`), which
1706/// reproduces exactly the one tier this function *can* fully classify
1707/// without an `ImportScope`: [`Candidacy::InScope`]'s "referrer and
1708/// candidate share a declared module" rule (issue #2233 — the fix for the
1709/// disagreement this doc used to call out; see below). This holds even when
1710/// the std candidate is the function's *sole* match: it is filtered out
1711/// before it can ever become `sole` whenever `referrer_module` doesn't match
1712/// it, so the name resolves as though that candidate did not exist, rather
1713/// than being returned.
1714///
1715/// For every other case, [`lookup_by_name_direct`]'s own "byte-identity
1716/// guarantee" fast path returns the sole non-std (or referrer-module-owned
1717/// std) candidate of the requested kinds *unconditionally, ignoring the rest
1718/// of the import scope*; the scope is consulted only once `multiple` is set.
1719/// So whenever this function returns `Some(id)`, [`lookup_by_name`] returns
1720/// the same `id` for any scope whose `file_module` equals this
1721/// `referrer_module` — pinned by `unique_lookup_agrees_with_scoped_lookup`
1722/// and, for the std case specifically, by
1723/// `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`. When
1724/// it returns `None` on an ambiguous name, the caller must fall back to
1725/// whatever it did before rather than guess.
1726///
1727/// [`BodyCtx`]: crate::infer
1728pub(crate) fn lookup_unique_by_name(
1729 index: &SymbolIndex,
1730 name: &str,
1731 kinds: &[SymbolKind],
1732 referrer_module: Option<&str>,
1733) -> Option<DefinitionId> {
1734 let ids = index.by_name.get(name)?;
1735 let mut sole = None;
1736 for id in ids {
1737 let Some(info) = index.symbols.get(id) else {
1738 continue;
1739 };
1740 if !kinds.contains(&info.kind) {
1741 continue;
1742 }
1743 // Issue #2216, narrowed by #2233: this function has no full
1744 // `ImportScope`, so — mirroring `lookup_by_name_direct`'s
1745 // std-invisibility gate for the case where a candidate can never
1746 // classify `Imported` here — a std-mounted candidate must never win
1747 // the sole-match count, even alone, UNLESS it is declared in the
1748 // referrer's own module: that is exactly `Candidacy::InScope`'s
1749 // "referrer and candidate share a declared module" rule, the one
1750 // tier this function can reproduce without a full scope. A std
1751 // candidate in a *different* std module than the referrer's still
1752 // falls through to `Other` and is excluded, matching
1753 // `lookup_by_name`'s behavior for a referrer that has not imported
1754 // that sibling module.
1755 //
1756 // #2251: generalized from the single-root `is_std_module` to
1757 // `is_reserved_root_module` — same reasoning as
1758 // `lookup_by_name_direct`'s exclusion above.
1759 if info.module.as_deref().is_some_and(is_reserved_root_module)
1760 && info.module.as_deref() != referrer_module
1761 {
1762 continue;
1763 }
1764 if sole.is_some() {
1765 return None;
1766 }
1767 sole = Some(*id);
1768 }
1769 sole
1770}
1771
1772/// Result of a bare list item lookup.
1773///
1774/// `pub(crate)` (issue #628): the phase-0 `Sig` stub's list-literal type
1775/// inference (`external_check::resolve_list_item_name`) reuses this exact
1776/// bare-name resolution — the declaring LIST is always a project-global
1777/// lookup, never locally scoped, so that stub can call straight in without
1778/// threading an `ImportScope` through `signature()`.
1779pub(crate) enum BareItemResult {
1780 /// Exactly one match.
1781 Unique(DefinitionId),
1782 /// Multiple matches across different lists — caller must qualify.
1783 Ambiguous,
1784 /// No match found.
1785 NotFound,
1786}
1787
1788/// Look up a list item by its bare (unqualified) name.
1789/// Searches all `ListName.ItemName` entries for a suffix match.
1790/// Returns `Ambiguous` if multiple lists contain an item with this name.
1791pub(crate) fn lookup_list_item_bare(index: &SymbolIndex, bare_name: &str) -> BareItemResult {
1792 let suffix = format!(".{bare_name}");
1793 let mut found: Option<DefinitionId> = None;
1794 for (name, ids) in &index.by_name {
1795 if name.ends_with(&suffix) {
1796 for id in ids {
1797 if let Some(info) = index.symbols.get(id)
1798 && info.kind == SymbolKind::ListItem
1799 {
1800 if found.is_some() {
1801 return BareItemResult::Ambiguous;
1802 }
1803 found = Some(*id);
1804 }
1805 }
1806 }
1807 }
1808 match found {
1809 Some(id) => BareItemResult::Unique(id),
1810 None => BareItemResult::NotFound,
1811 }
1812}
1813
1814/// Look up a label within a knot scope. Searches for `knot.label` and
1815/// `knot.*.label` patterns.
1816fn lookup_label_in_knot(
1817 index: &SymbolIndex,
1818 scope: &ImportScope,
1819 knot: &str,
1820 label: &str,
1821) -> Option<DefinitionId> {
1822 // Try knot.label
1823 let direct = format!("{knot}.{label}");
1824 if let Some(id) = lookup_by_name(index, scope, &direct, &[SymbolKind::Label]) {
1825 return Some(id);
1826 }
1827
1828 // Try knot.*.label (any stitch within this knot).
1829 // Collect all matches and pick the smallest `DefinitionId` for determinism,
1830 // since `HashMap` iteration order is not stable across processes.
1831 let suffix = format!(".{label}");
1832 let prefix = format!("{knot}.");
1833 let mut best: Option<DefinitionId> = None;
1834 for (name, ids) in &index.by_name {
1835 if name.starts_with(&prefix) && name.ends_with(&suffix) && name.matches('.').count() == 2 {
1836 for id in ids {
1837 if let Some(info) = index.symbols.get(id)
1838 && info.kind == SymbolKind::Label
1839 {
1840 best = Some(match best {
1841 Some(prev) if prev.to_raw() <= id.to_raw() => prev,
1842 _ => *id,
1843 });
1844 }
1845 }
1846 }
1847 }
1848 best
1849}
1850
1851fn ambiguous_diag(file: FileId, range: rowan::TextRange, path: &str) -> Diagnostic {
1852 Diagnostic {
1853 file,
1854 range,
1855 message: format!(
1856 "{}: `{path}` — qualify with the list name (e.g., `ListName.{path}`)",
1857 DiagnosticCode::E027.title(),
1858 ),
1859 code: DiagnosticCode::E027,
1860 }
1861}
1862
1863/// True when `index` holds at least one declared symbol named `path`
1864/// whose module is a reserved peer root ([`is_reserved_root_module`]) — regardless
1865/// of `SymbolKind`, since the point here is not "which lookup would have
1866/// matched" but "does bare-name resolution's std-invisibility gate
1867/// (`lookup_by_name`, `lookup_unique_by_name`) explain why this path came
1868/// back empty".
1869///
1870/// Issue #2217: the gate that excludes std candidates from bare-name
1871/// resolution (`lookup_by_name`'s `Candidacy::Other` skip,
1872/// `lookup_unique_by_name`'s unconditional skip) applies identically to
1873/// the *embedded* stdlib mount and to a project's own file that legitimately
1874/// lives at a `std/…` path (`brink_environment::mount_stdlib`'s "project
1875/// source at the same key wins" carve-out — both mint the identical
1876/// `std::…` module identity via `native_module_path`, by design; see
1877/// `docs/modules-spec.md` §4 "peer roots"). Reconsidering `is_std_module`
1878/// to distinguish the two would undo that ruling, so this does not attempt
1879/// it — it only turns the resulting unexplained E024/E025/E068 into a
1880/// diagnosable one, without inventing a new diagnostic code.
1881fn is_std_shadowed_name(index: &SymbolIndex, path: &str) -> bool {
1882 index.by_name.get(path).is_some_and(|ids| {
1883 ids.iter().any(|id| {
1884 index
1885 .symbols
1886 .get(id)
1887 .is_some_and(|info| info.module.as_deref().is_some_and(is_reserved_root_module))
1888 })
1889 })
1890}
1891
1892/// Look for a candidate named `path`, of one of `kinds`, that resolution
1893/// skipped specifically because it is reachable only via a **qualified**
1894/// module import ([`is_qualified_import_only`]) — the "module-imported-
1895/// but-bare" row of the four-row resolution table issues #2287/#2296
1896/// worked through for diverts and #2298 extended to knot-as-tunnel-
1897/// function calls and `lookup_divert`'s remaining steps. When one exists,
1898/// [`unresolved_diag`] threads its module into the message so this row
1899/// gets the same "import it from `module`" framing `modules::check`'s own
1900/// E025 already gives the "no import at all" row (issue #2298 item 3),
1901/// instead of a bare, unexplained unresolved-reference message.
1902///
1903/// `kinds` must be exactly the symbol kinds the caller's own lookup chain
1904/// actually applied the `is_qualified_import_only` exclusion to — passing
1905/// a kind the caller never excluded (e.g. `External` at a function-call
1906/// site, which stays on the ordinary `classify` rule per this module's
1907/// top-of-file doc) would name a candidate that was never "skipped" at
1908/// all, misattributing an unrelated resolution failure. An empty slice
1909/// (every call site this exclusion does not apply to) always returns
1910/// `None`, leaving the message exactly as it was before this hint existed.
1911fn qualified_import_only_hint(
1912 index: &SymbolIndex,
1913 scope: &ImportScope,
1914 path: &str,
1915 kinds: &[SymbolKind],
1916) -> Option<String> {
1917 let ids = index.by_name.get(path)?;
1918 ids.iter().find_map(|id| {
1919 let info = index.symbols.get(id)?;
1920 if !kinds.contains(&info.kind) || !is_qualified_import_only(scope, info) {
1921 return None;
1922 }
1923 info.module.clone()
1924 })
1925}
1926
1927fn unresolved_diag(
1928 index: &SymbolIndex,
1929 scope: &ImportScope,
1930 file: FileId,
1931 range: rowan::TextRange,
1932 path: &str,
1933 code: DiagnosticCode,
1934 qualified_only_kinds: &[SymbolKind],
1935) -> Diagnostic {
1936 let message = if is_std_shadowed_name(index, path) {
1937 format!(
1938 "{}: `{path}` — a declaration of this name exists under the `std::` peer root \
1939 (either the mounted stdlib, or your own project file at a `std/…` path); bare \
1940 names under `std::` are invisible outside it by rule, not by mistake — reference \
1941 it with `use std::…` (docs/modules-spec.md §4)",
1942 code.title(),
1943 )
1944 } else if let Some(module) =
1945 qualified_import_only_hint(index, scope, path, qualified_only_kinds)
1946 {
1947 format!(
1948 "{}: `{path}` — exported by `{module}`, which this file imports only as a module; \
1949 a module import never brings bare names into scope — import it from `{module}` \
1950 (see modules-spec §2)",
1951 code.title(),
1952 )
1953 } else {
1954 format!("{}: `{path}`", code.title())
1955 };
1956 Diagnostic {
1957 file,
1958 range,
1959 message,
1960 code,
1961 }
1962}
1963
1964#[cfg(test)]
1965#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
1966mod tests {
1967 use brink_ir::{DeclaredSymbol, ImportItem, Scope, UnresolvedRef};
1968 use rowan::TextRange;
1969 use rowan::TextSize;
1970
1971 use super::*;
1972 use crate::manifest::merge_manifests;
1973
1974 fn range(offset: u32, len: u32) -> TextRange {
1975 TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
1976 }
1977
1978 fn make_manifest(
1979 knots: &[&str],
1980 stitches: &[&str],
1981 variables: &[&str],
1982 lists: &[(&str, &[&str])],
1983 externals: &[&str],
1984 labels: &[&str],
1985 unresolved: Vec<UnresolvedRef>,
1986 ) -> SymbolManifest {
1987 let mut manifest = SymbolManifest::default();
1988 let mut offset = 0u32;
1989
1990 for &name in knots {
1991 let r = range(offset, name.len() as u32);
1992 manifest.knots.push(DeclaredSymbol {
1993 name: name.to_string(),
1994 range: r,
1995 params: Vec::new(),
1996 detail: None,
1997 visibility: None,
1998 was: None,
1999 });
2000 offset += name.len() as u32 + 1;
2001 }
2002 for &name in stitches {
2003 let r = range(offset, name.len() as u32);
2004 manifest.stitches.push(DeclaredSymbol {
2005 name: name.to_string(),
2006 range: r,
2007 params: Vec::new(),
2008 detail: None,
2009 visibility: None,
2010 was: None,
2011 });
2012 offset += name.len() as u32 + 1;
2013 }
2014 for &name in variables {
2015 let r = range(offset, name.len() as u32);
2016 manifest.variables.push(DeclaredSymbol {
2017 name: name.to_string(),
2018 range: r,
2019 params: Vec::new(),
2020 detail: None,
2021 visibility: None,
2022 was: None,
2023 });
2024 offset += name.len() as u32 + 1;
2025 }
2026 for &(list_name, items) in lists {
2027 let r = range(offset, list_name.len() as u32);
2028 manifest.lists.push(DeclaredSymbol {
2029 name: list_name.to_string(),
2030 range: r,
2031 params: Vec::new(),
2032 detail: None,
2033 visibility: None,
2034 was: None,
2035 });
2036 offset += list_name.len() as u32 + 1;
2037 for &item in items {
2038 let qualified = format!("{list_name}.{item}");
2039 let r = range(offset, item.len() as u32);
2040 manifest.list_items.push(DeclaredSymbol {
2041 name: qualified,
2042 range: r,
2043 params: Vec::new(),
2044 detail: None,
2045 visibility: None,
2046 was: None,
2047 });
2048 offset += item.len() as u32 + 1;
2049 }
2050 }
2051 for &name in externals {
2052 let r = range(offset, name.len() as u32);
2053 manifest.externals.push(DeclaredSymbol {
2054 name: name.to_string(),
2055 range: r,
2056 params: Vec::new(),
2057 detail: None,
2058 visibility: None,
2059 was: None,
2060 });
2061 offset += name.len() as u32 + 1;
2062 }
2063 for &name in labels {
2064 let r = range(offset, name.len() as u32);
2065 manifest.labels.push(DeclaredSymbol {
2066 name: name.to_string(),
2067 range: r,
2068 params: Vec::new(),
2069 detail: None,
2070 visibility: None,
2071 was: None,
2072 });
2073 offset += name.len() as u32 + 1;
2074 }
2075 manifest.unresolved = unresolved;
2076 manifest
2077 }
2078
2079 fn uref(path: &str, kind: RefKind, knot: Option<&str>, stitch: Option<&str>) -> UnresolvedRef {
2080 uref_with_args(path, kind, knot, stitch, None)
2081 }
2082
2083 fn uref_with_args(
2084 path: &str,
2085 kind: RefKind,
2086 knot: Option<&str>,
2087 stitch: Option<&str>,
2088 arg_count: Option<usize>,
2089 ) -> UnresolvedRef {
2090 UnresolvedRef {
2091 path: path.to_string(),
2092 range: range(900, path.len() as u32),
2093 kind,
2094 scope: Scope {
2095 knot: knot.map(String::from),
2096 stitch: stitch.map(String::from),
2097 },
2098 arg_count,
2099 module_qualified: false,
2100 }
2101 }
2102
2103 #[test]
2104 fn single_knot_divert_resolves() {
2105 let manifest = make_manifest(
2106 &["start"],
2107 &[],
2108 &[],
2109 &[],
2110 &[],
2111 &[],
2112 vec![uref("start", RefKind::Divert, None, None)],
2113 );
2114 let files = vec![(FileId(0), &manifest)];
2115 let (index, merge_diags) = merge_manifests(&files);
2116 let (resolutions, resolve_diags) = resolve_refs(&index, &files);
2117
2118 assert!(merge_diags.is_empty());
2119 assert!(resolve_diags.is_empty());
2120 assert_eq!(resolutions.len(), 1);
2121 assert_eq!(resolutions[0].file, FileId(0));
2122 }
2123
2124 #[test]
2125 fn qualified_knot_stitch_divert_resolves() {
2126 let manifest = make_manifest(
2127 &["kitchen"],
2128 &["kitchen.look_around"],
2129 &[],
2130 &[],
2131 &[],
2132 &[],
2133 vec![uref("kitchen.look_around", RefKind::Divert, None, None)],
2134 );
2135 let files = vec![(FileId(0), &manifest)];
2136 let (index, _) = merge_manifests(&files);
2137 let (resolutions, diags) = resolve_refs(&index, &files);
2138
2139 assert!(diags.is_empty());
2140 assert_eq!(resolutions.len(), 1);
2141 }
2142
2143 #[test]
2144 fn stitch_local_divert_prefers_local_stitch() {
2145 let manifest = make_manifest(
2146 &["bedroom", "kitchen"],
2147 &["bedroom.look", "kitchen.look"],
2148 &[],
2149 &[],
2150 &[],
2151 &[],
2152 vec![uref("look", RefKind::Divert, Some("bedroom"), None)],
2153 );
2154 let files = vec![(FileId(0), &manifest)];
2155 let (index, _) = merge_manifests(&files);
2156 let (resolutions, diags) = resolve_refs(&index, &files);
2157
2158 assert!(diags.is_empty());
2159 assert_eq!(resolutions.len(), 1);
2160 // The resolved ID should be for bedroom.look
2161 let info = index.symbols.get(&resolutions[0].target).unwrap();
2162 assert_eq!(info.name, "bedroom.look");
2163 }
2164
2165 #[test]
2166 fn unresolved_divert_emits_diagnostic() {
2167 let manifest = make_manifest(
2168 &["start"],
2169 &[],
2170 &[],
2171 &[],
2172 &[],
2173 &[],
2174 vec![uref("nonexistent", RefKind::Divert, None, None)],
2175 );
2176 let files = vec![(FileId(0), &manifest)];
2177 let (index, _) = merge_manifests(&files);
2178 let (resolutions, diags) = resolve_refs(&index, &files);
2179
2180 assert!(resolutions.is_empty());
2181 assert_eq!(diags.len(), 1);
2182 assert_eq!(diags[0].code, DiagnosticCode::E024);
2183 }
2184
2185 #[test]
2186 fn duplicate_knot_emits_warning() {
2187 let mut m1 = make_manifest(&["start"], &[], &[], &[], &[], &[], vec![]);
2188 let m2 = make_manifest(&["start"], &[], &[], &[], &[], &[], vec![]);
2189
2190 // Give m1 different range so they don't collide
2191 m1.knots[0].range = range(0, 5);
2192
2193 let files = vec![(FileId(0), &m1), (FileId(1), &m2)];
2194 let (_index, diags) = merge_manifests(&files);
2195
2196 // Inklecate permits duplicate definitions — we warn but don't error.
2197 assert_eq!(diags.len(), 1);
2198 assert_eq!(diags[0].code, DiagnosticCode::E022);
2199 }
2200
2201 #[test]
2202 fn cross_file_duplicate_knot_local_does_not_leak_across_files() {
2203 // #517 (finding-4 fix): file B has a duplicate knot name (`dup`,
2204 // warned E022) but does *not* declare its own `t`. Before the
2205 // locals split, `lookup_local_in_scope` searched the merged index
2206 // and would (wrongly) resolve B's reference against A's
2207 // same-scoped `t`, since the lookup never checked which file a
2208 // candidate came from. After the split, resolution reads only the
2209 // referencing file's own `manifest.locals`, so B's reference to an
2210 // undeclared `t` must fail to resolve instead of silently aliasing
2211 // A's declaration.
2212 let mut a = SymbolManifest::default();
2213 a.knots.push(brink_ir::DeclaredSymbol {
2214 name: "dup".to_string(),
2215 range: range(0, 3),
2216 params: Vec::new(),
2217 detail: None,
2218 visibility: None,
2219 was: None,
2220 });
2221 a.locals.push(LocalSymbol {
2222 name: "t".to_string(),
2223 range: range(10, 1),
2224 scope: Scope {
2225 knot: Some("dup".to_string()),
2226 stitch: None,
2227 },
2228 kind: SymbolKind::Temp,
2229 param_detail: None,
2230 annotation: None,
2231 });
2232
2233 let mut b = SymbolManifest::default();
2234 b.knots.push(brink_ir::DeclaredSymbol {
2235 name: "dup".to_string(),
2236 range: range(100, 3),
2237 params: Vec::new(),
2238 detail: None,
2239 visibility: None,
2240 was: None,
2241 }); // duplicate name -> E022, not indexed
2242 b.unresolved
2243 .push(uref("t", RefKind::Variable, Some("dup"), None));
2244
2245 let files = vec![(FileId(0), &a), (FileId(1), &b)];
2246 let (index, merge_diags) = merge_manifests(&files);
2247 assert_eq!(merge_diags.len(), 1, "duplicate knot should warn once");
2248 assert_eq!(merge_diags[0].code, DiagnosticCode::E022);
2249
2250 let (resolutions, diags) = resolve_refs(&index, &files);
2251 assert!(
2252 resolutions.is_empty(),
2253 "B's reference to an undeclared local must not resolve, got {resolutions:?}"
2254 );
2255 assert_eq!(diags.len(), 1);
2256 assert_eq!(diags[0].code, DiagnosticCode::E025);
2257 }
2258
2259 #[test]
2260 fn list_item_bare_name_resolves() {
2261 let manifest = make_manifest(
2262 &[],
2263 &[],
2264 &[],
2265 &[("Colors", &["red", "green", "blue"])],
2266 &[],
2267 &[],
2268 vec![uref("red", RefKind::Variable, None, None)],
2269 );
2270 let files = vec![(FileId(0), &manifest)];
2271 let (index, _) = merge_manifests(&files);
2272 let (resolutions, diags) = resolve_refs(&index, &files);
2273
2274 assert!(diags.is_empty());
2275 assert_eq!(resolutions.len(), 1);
2276 let info = index.symbols.get(&resolutions[0].target).unwrap();
2277 assert_eq!(info.name, "Colors.red");
2278 }
2279
2280 #[test]
2281 fn end_done_not_in_unresolved() {
2282 // END/DONE are handled as DivertPath::End/Done at the HIR level,
2283 // so they never appear as UnresolvedRef entries. This test verifies
2284 // that the resolution pass doesn't get confused by them.
2285 let manifest = make_manifest(
2286 &["start"],
2287 &[],
2288 &[],
2289 &[],
2290 &[],
2291 &[],
2292 vec![], // No unresolved refs for END/DONE
2293 );
2294 let files = vec![(FileId(0), &manifest)];
2295 let (index, _) = merge_manifests(&files);
2296 let (resolutions, diags) = resolve_refs(&index, &files);
2297
2298 assert!(diags.is_empty());
2299 assert!(resolutions.is_empty());
2300 }
2301
2302 #[test]
2303 fn label_in_knot_resolves() {
2304 let manifest = make_manifest(
2305 &["meeting"],
2306 &[],
2307 &[],
2308 &[],
2309 &[],
2310 &["meeting.greet"],
2311 vec![uref("greet", RefKind::Divert, Some("meeting"), None)],
2312 );
2313 let files = vec![(FileId(0), &manifest)];
2314 let (index, _) = merge_manifests(&files);
2315 let (resolutions, diags) = resolve_refs(&index, &files);
2316
2317 assert!(diags.is_empty());
2318 assert_eq!(resolutions.len(), 1);
2319 let info = index.symbols.get(&resolutions[0].target).unwrap();
2320 assert_eq!(info.name, "meeting.greet");
2321 }
2322
2323 #[test]
2324 fn external_function_resolves() {
2325 let manifest = make_manifest(
2326 &[],
2327 &[],
2328 &[],
2329 &[],
2330 &["print_debug"],
2331 &[],
2332 vec![uref("print_debug", RefKind::Function, None, None)],
2333 );
2334 let files = vec![(FileId(0), &manifest)];
2335 let (index, _) = merge_manifests(&files);
2336 let (resolutions, diags) = resolve_refs(&index, &files);
2337
2338 assert!(diags.is_empty());
2339 assert_eq!(resolutions.len(), 1);
2340 }
2341
2342 #[test]
2343 fn global_variable_resolves() {
2344 let manifest = make_manifest(
2345 &[],
2346 &[],
2347 &["player_name"],
2348 &[],
2349 &[],
2350 &[],
2351 vec![uref("player_name", RefKind::Variable, None, None)],
2352 );
2353 let files = vec![(FileId(0), &manifest)];
2354 let (index, _) = merge_manifests(&files);
2355 let (resolutions, diags) = resolve_refs(&index, &files);
2356
2357 assert!(diags.is_empty());
2358 assert_eq!(resolutions.len(), 1);
2359 }
2360
2361 #[test]
2362 fn ambiguous_bare_list_item_emits_diagnostic() {
2363 let manifest = make_manifest(
2364 &[],
2365 &[],
2366 &[],
2367 &[("Fruit", &["red"]), ("Color", &["red"])],
2368 &[],
2369 &[],
2370 vec![uref("red", RefKind::Variable, None, None)],
2371 );
2372 let files = vec![(FileId(0), &manifest)];
2373 let (index, _) = merge_manifests(&files);
2374 let (resolutions, diags) = resolve_refs(&index, &files);
2375
2376 assert!(resolutions.is_empty());
2377 assert_eq!(diags.len(), 1);
2378 assert_eq!(diags[0].code, DiagnosticCode::E027);
2379 }
2380
2381 #[test]
2382 fn qualified_list_item_resolves_despite_ambiguity() {
2383 let manifest = make_manifest(
2384 &[],
2385 &[],
2386 &[],
2387 &[("Fruit", &["red"]), ("Color", &["red"])],
2388 &[],
2389 &[],
2390 vec![uref("Color.red", RefKind::Variable, None, None)],
2391 );
2392 let files = vec![(FileId(0), &manifest)];
2393 let (index, _) = merge_manifests(&files);
2394 let (resolutions, diags) = resolve_refs(&index, &files);
2395
2396 assert!(diags.is_empty());
2397 assert_eq!(resolutions.len(), 1);
2398 let info = index.symbols.get(&resolutions[0].target).unwrap();
2399 assert_eq!(info.name, "Color.red");
2400 }
2401
2402 // ── Arity checking ──────────────────────────────────────────────
2403
2404 /// Make a manifest with a knot that has a specific number of params.
2405 fn make_manifest_with_params(
2406 knot_name: &str,
2407 param_count: usize,
2408 unresolved: Vec<UnresolvedRef>,
2409 ) -> SymbolManifest {
2410 let mut manifest = SymbolManifest::default();
2411 let r = range(0, knot_name.len() as u32);
2412 let params: Vec<brink_ir::ParamInfo> = (0..param_count)
2413 .map(|i| brink_ir::ParamInfo {
2414 name: format!("p{i}"),
2415 is_ref: false,
2416 is_divert: false,
2417 })
2418 .collect();
2419 manifest.knots.push(DeclaredSymbol {
2420 name: knot_name.to_string(),
2421 range: r,
2422 params,
2423 detail: Some("function".to_string()),
2424 visibility: None,
2425 was: None,
2426 });
2427 manifest.unresolved = unresolved;
2428 manifest
2429 }
2430
2431 #[test]
2432 fn arity_match_no_warning() {
2433 // Call `greet(x)` where greet takes 1 param — no warning.
2434 let manifest = make_manifest_with_params(
2435 "greet",
2436 1,
2437 vec![uref_with_args(
2438 "greet",
2439 RefKind::Function,
2440 None,
2441 None,
2442 Some(1),
2443 )],
2444 );
2445 let files = vec![(FileId(0), &manifest)];
2446 let (index, _) = merge_manifests(&files);
2447 let (resolutions, diags) = resolve_refs(&index, &files);
2448
2449 assert_eq!(resolutions.len(), 1);
2450 assert!(
2451 diags.is_empty(),
2452 "expected no diagnostics for matching arity, got: {diags:?}"
2453 );
2454 }
2455
2456 #[test]
2457 fn arity_mismatch_emits_e031() {
2458 // Call `greet(x, y)` where greet takes 1 param — E031 warning.
2459 let manifest = make_manifest_with_params(
2460 "greet",
2461 1,
2462 vec![uref_with_args(
2463 "greet",
2464 RefKind::Function,
2465 None,
2466 None,
2467 Some(2),
2468 )],
2469 );
2470 let files = vec![(FileId(0), &manifest)];
2471 let (index, _) = merge_manifests(&files);
2472 let (resolutions, diags) = resolve_refs(&index, &files);
2473
2474 assert_eq!(
2475 resolutions.len(),
2476 1,
2477 "should still resolve despite arity mismatch"
2478 );
2479 assert_eq!(diags.len(), 1);
2480 assert_eq!(diags[0].code, DiagnosticCode::E031);
2481 assert!(diags[0].message.contains("expects 1"));
2482 assert!(diags[0].message.contains("got 2"));
2483 }
2484
2485 #[test]
2486 fn arity_check_no_arg_count_no_warning() {
2487 // A ref with `arg_count: None` (not a call site at all) should
2488 // never trigger arity checking, regardless of kind. This uses
2489 // `uref`'s hardcoded `None` directly rather than a real divert
2490 // pipeline — since issue #2156 a *real* `RefKind::Divert` ref
2491 // always carries `Some(target.args.len())`
2492 // (`brink_ir::symbols::project`'s `walk_divert_target`); see
2493 // `divert_arity_mismatch_emits_e176` below for that path.
2494 let manifest =
2495 make_manifest_with_params("greet", 1, vec![uref("greet", RefKind::Divert, None, None)]);
2496 let files = vec![(FileId(0), &manifest)];
2497 let (index, _) = merge_manifests(&files);
2498 let (_resolutions, diags) = resolve_refs(&index, &files);
2499
2500 assert!(
2501 diags.is_empty(),
2502 "a ref with no arg_count should not trigger arity check: {diags:?}"
2503 );
2504 }
2505
2506 /// Make a manifest with a top-level knot that has a specific number of
2507 /// params, alongside a top-level `Variable` — for the divert-arity
2508 /// (`E176`, issue #2156) test family below.
2509 fn make_manifest_with_knot_and_variable(
2510 knot_name: &str,
2511 param_count: usize,
2512 variable_name: &str,
2513 unresolved: Vec<UnresolvedRef>,
2514 ) -> SymbolManifest {
2515 let mut manifest = make_manifest_with_params(knot_name, param_count, Vec::new());
2516 let r = range(9000, variable_name.len() as u32);
2517 manifest.variables.push(DeclaredSymbol {
2518 name: variable_name.to_string(),
2519 range: r,
2520 params: Vec::new(),
2521 detail: None,
2522 visibility: None,
2523 was: None,
2524 });
2525 manifest.unresolved = unresolved;
2526 manifest
2527 }
2528
2529 #[test]
2530 fn divert_arity_match_emits_no_e176() {
2531 // `-> greet(x)` where `greet` takes 1 param — no warning.
2532 let manifest = make_manifest_with_params(
2533 "greet",
2534 1,
2535 vec![uref_with_args(
2536 "greet",
2537 RefKind::Divert,
2538 None,
2539 None,
2540 Some(1),
2541 )],
2542 );
2543 let files = vec![(FileId(0), &manifest)];
2544 let (index, _) = merge_manifests(&files);
2545 let (resolutions, diags) = resolve_refs(&index, &files);
2546
2547 assert_eq!(resolutions.len(), 1);
2548 assert!(
2549 diags.is_empty(),
2550 "expected no diagnostics for matching divert arity, got: {diags:?}"
2551 );
2552 }
2553
2554 #[test]
2555 fn divert_arity_mismatch_emits_e176() {
2556 // `-> greet(x, y)` where `greet` takes 1 param — E176 warning, not
2557 // E031 (that code stays scoped to ordinary calls; this is its
2558 // sibling for the divert shape).
2559 let manifest = make_manifest_with_params(
2560 "greet",
2561 1,
2562 vec![uref_with_args(
2563 "greet",
2564 RefKind::Divert,
2565 None,
2566 None,
2567 Some(2),
2568 )],
2569 );
2570 let files = vec![(FileId(0), &manifest)];
2571 let (index, _) = merge_manifests(&files);
2572 let (resolutions, diags) = resolve_refs(&index, &files);
2573
2574 assert_eq!(
2575 resolutions.len(),
2576 1,
2577 "should still resolve despite arity mismatch"
2578 );
2579 assert_eq!(diags.len(), 1);
2580 assert_eq!(diags[0].code, DiagnosticCode::E176);
2581 assert!(diags[0].message.contains("expects 1"));
2582 assert!(diags[0].message.contains("got 2"));
2583 }
2584
2585 #[test]
2586 fn divert_through_variable_is_not_arity_checked() {
2587 // `-> holder` where `holder` is a `Variable` (holding a stored
2588 // divert-target value, e.g. ink's "Advanced: sending divert
2589 // targets as parameters") must never be arity-checked: a
2590 // `Variable` symbol carries no declared parameter row of its own,
2591 // so checking `arg_count` against it would misfire on legitimate
2592 // code. `lookup_divert`'s case 6 (bare-name fallback to a
2593 // `Variable`) is exactly the resolution this exercises — the knot
2594 // `greet` (1 param) is a decoy that must NOT be what `holder`
2595 // resolves to.
2596 let manifest = make_manifest_with_knot_and_variable(
2597 "greet",
2598 1,
2599 "holder",
2600 vec![uref_with_args(
2601 "holder",
2602 RefKind::Divert,
2603 None,
2604 None,
2605 Some(3),
2606 )],
2607 );
2608 let files = vec![(FileId(0), &manifest)];
2609 let (index, _) = merge_manifests(&files);
2610 let (resolutions, diags) = resolve_refs(&index, &files);
2611
2612 assert_eq!(resolutions.len(), 1, "the Variable must still resolve");
2613 assert!(
2614 diags.is_empty(),
2615 "a divert through a Variable resolution must never be arity-checked: {diags:?}"
2616 );
2617 }
2618
2619 #[test]
2620 fn arity_mismatch_external() {
2621 // Call external with wrong arity.
2622 let mut manifest = SymbolManifest::default();
2623 let r = range(0, 5);
2624 manifest.externals.push(DeclaredSymbol {
2625 name: "print".to_string(),
2626 range: r,
2627 params: vec![brink_ir::ParamInfo {
2628 name: "msg".into(),
2629 is_ref: false,
2630 is_divert: false,
2631 }],
2632 detail: None,
2633 visibility: None,
2634 was: None,
2635 });
2636 manifest.unresolved.push(uref_with_args(
2637 "print",
2638 RefKind::Function,
2639 None,
2640 None,
2641 Some(3),
2642 ));
2643 let files = vec![(FileId(0), &manifest)];
2644 let (index, _) = merge_manifests(&files);
2645 let (resolutions, diags) = resolve_refs(&index, &files);
2646
2647 assert_eq!(resolutions.len(), 1);
2648 assert_eq!(diags.len(), 1);
2649 assert_eq!(diags[0].code, DiagnosticCode::E031);
2650 }
2651
2652 // ── TM-4b resolution fallback (docs/typed-mode-spec.md §6) ─────────
2653 //
2654 // End-to-end (parse -> HIR lower -> merge_manifests -> resolve_file)
2655 // rather than the hand-built `make_manifest` fixtures above: the
2656 // precedence claim is about how a *real* dotted `Path` — produced by
2657 // the actual parser/lowering pipeline, not a synthesized
2658 // `UnresolvedRef` — resolves, so the fixture needs the real pipeline to
2659 // be a faithful test of the claim.
2660
2661 /// Parse -> HIR lower -> `merge_manifests` -> `resolve_file` (the real
2662 /// production pipeline). Each fixture below is written to produce
2663 /// exactly one resolvable reference (the LHS of `~ y = …` is an
2664 /// undeclared `y`, so it stays unresolved — diagnosed, not resolved —
2665 /// and `-> DONE` is handled specially at the HIR level, never a
2666 /// resolvable reference), so `resolutions` always has exactly one entry:
2667 /// the dotted/bare reference under test.
2668 fn build_real(src: &str) -> (SymbolIndex, ResolutionMap) {
2669 let parsed = brink_syntax::parse(src);
2670 let (_hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
2671 let (index, _diag) = merge_manifests(&[(FileId(0), &manifest)]);
2672 let (resolutions, _diag) =
2673 resolve_file(&index, &ImportScope::default(), FileId(0), &manifest);
2674 (index, resolutions)
2675 }
2676
2677 /// The `SymbolKind` a reference spanning exactly `needle` (the first
2678 /// occurrence of that substring in `src`) resolved to. Ink's own
2679 /// lowering can register additional bookkeeping references beyond the
2680 /// one under test (e.g. an implicit fallthrough divert when a knot's
2681 /// only content is its first stitch) — matching by the reference's
2682 /// exact source span, rather than assuming `resolutions` has exactly
2683 /// one entry, is robust to that noise.
2684 fn resolved_kind_at(
2685 src: &str,
2686 index: &SymbolIndex,
2687 resolutions: &ResolutionMap,
2688 needle: &str,
2689 ) -> SymbolKind {
2690 // The *last* occurrence — every fixture below places the reference
2691 // under test after any same-named declaration.
2692 let start = src
2693 .rfind(needle)
2694 .expect("needle not found in fixture source");
2695 #[expect(
2696 clippy::cast_possible_truncation,
2697 reason = "test fixture offsets fit in u32"
2698 )]
2699 let range = rowan::TextRange::new(
2700 rowan::TextSize::from(start as u32),
2701 rowan::TextSize::from((start + needle.len()) as u32),
2702 );
2703 let target = resolutions
2704 .iter()
2705 .find(|r| r.range == range)
2706 .expect("no resolution spanning the needle's exact range")
2707 .target;
2708 index
2709 .symbols
2710 .get(&target)
2711 .expect("resolved target missing from index")
2712 .kind
2713 }
2714
2715 #[test]
2716 fn resolution_fallback_static_dotted_path_wins_over_a_colliding_variable_name() {
2717 // §6's own precedence claim: "ink's static dotted paths (knot.stitch,
2718 // List.Item) are resolved first and win". `knot` here is BOTH the
2719 // head of a real stitch path (`knot.stitch`) AND the name of a
2720 // declared variable — the static path must win, resolving `knot.x`
2721 // to the stitch, not falling back to field access on the variable.
2722 let src = "VAR knot = 0\n=== knot ===\n= x\nHello.\n-> DONE\n\
2723 === main ===\n~ y = knot.x\n-> DONE\n";
2724 let (index, resolutions) = build_real(src);
2725 assert_eq!(
2726 resolved_kind_at(src, &index, &resolutions, "knot.x"),
2727 SymbolKind::Stitch,
2728 "the static `knot.x` stitch path must win over the colliding `knot` variable"
2729 );
2730 }
2731
2732 #[test]
2733 fn resolution_fallback_resolves_to_head_variable_when_no_static_path_matches() {
2734 // No knot/stitch/list/label named `p` or `p.x` exists — the fallback
2735 // resolves `p.x` to the variable `p` itself (field access on it),
2736 // not an unresolved reference.
2737 let src = "VAR p = 0\n=== main ===\n~ y = p.x\n-> DONE\n";
2738 let (index, resolutions) = build_real(src);
2739 assert_eq!(
2740 resolved_kind_at(src, &index, &resolutions, "p.x"),
2741 SymbolKind::Variable
2742 );
2743 }
2744
2745 #[test]
2746 fn resolution_fallback_resolves_to_head_param() {
2747 // Same fallback, but the head is a knot parameter rather than a
2748 // global — the fallback checks locals (params/temps) first, per
2749 // `lookup_variable`'s existing local-shadows-global ordering.
2750 let src = "=== main(p) ===\n~ y = p.x\n-> DONE\n";
2751 let (index, resolutions) = build_real(src);
2752 assert_eq!(
2753 resolved_kind_at(src, &index, &resolutions, "p.x"),
2754 SymbolKind::Param
2755 );
2756 }
2757
2758 #[test]
2759 fn resolution_fallback_does_not_apply_to_a_single_segment_path() {
2760 // A bare `p` (no dot at all) must resolve exactly as it always has
2761 // — the fallback is gated on `path.contains('.')` and must never
2762 // fire for an ordinary single-segment variable reference.
2763 let src = "VAR p = 0\n=== main ===\n~ y = p\n-> DONE\n";
2764 let (index, resolutions) = build_real(src);
2765 assert_eq!(
2766 resolved_kind_at(src, &index, &resolutions, "p"),
2767 SymbolKind::Variable
2768 );
2769 }
2770
2771 #[test]
2772 fn struct_literal_resolves_shape_name_to_the_declared_struct() {
2773 let src = "STRUCT Point = #{x: float}\n=== main ===\n~ p = Point#{x: 1.0}\n-> DONE\n";
2774 let (index, resolutions) = build_real(src);
2775 assert_eq!(
2776 resolved_kind_at(src, &index, &resolutions, "Point"),
2777 SymbolKind::Struct
2778 );
2779 }
2780
2781 #[test]
2782 fn struct_literal_unresolved_shape_name_is_e068() {
2783 let src = "=== main ===\n~ p = Bogus#{x: 1}\n-> DONE\n";
2784 let parsed = brink_syntax::parse(src);
2785 let (_hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
2786 let (index, _diag) = merge_manifests(&[(FileId(0), &manifest)]);
2787 let (resolutions, diags) =
2788 resolve_file(&index, &ImportScope::default(), FileId(0), &manifest);
2789 assert!(
2790 resolutions.is_empty(),
2791 "no resolution for an undeclared shape: {resolutions:?}"
2792 );
2793 assert!(
2794 diags.iter().any(|d| d.code == DiagnosticCode::E068),
2795 "{diags:?}"
2796 );
2797 }
2798
2799 // ── M-2d import-scoped lookup (issue #790) ────────────────────────
2800
2801 /// A hand-built index with two `Knot`s named `ambush` in *different*
2802 /// declared modules (the coexistence unlocked by relaxing the #784/#793
2803 /// stopgap). Insertion order is `quest_a`, then `quest_b`, so the flat
2804 /// first-winner is always `quest_a` — the import scope is what makes
2805 /// `quest_b` reachable.
2806 fn two_module_ambush_index() -> (SymbolIndex, DefinitionId, DefinitionId) {
2807 use brink_format::DefinitionTag;
2808 let mut index = SymbolIndex::default();
2809 let mk = |index: &mut SymbolIndex, module: &str, hash: u64| {
2810 let id = DefinitionId::new(DefinitionTag::Address, hash);
2811 index.symbols.insert(
2812 id,
2813 SymbolInfo {
2814 kind: SymbolKind::Knot,
2815 file: FileId(0),
2816 range: TextRange::default(),
2817 id,
2818 name: "ambush".to_string(),
2819 params: Vec::new(),
2820 detail: None,
2821 scope: None,
2822 param_detail: None,
2823 module: Some(module.to_string()),
2824 visibility: Visibility::Public,
2825 },
2826 );
2827 index
2828 .by_name
2829 .entry("ambush".to_string())
2830 .or_default()
2831 .push(id);
2832 id
2833 };
2834 let a = mk(&mut index, "quest_a", 0xA);
2835 let b = mk(&mut index, "quest_b", 0xB);
2836 (index, a, b)
2837 }
2838
2839 #[test]
2840 fn import_scope_binds_each_importer_to_its_own_module() {
2841 let (index, a, b) = two_module_ambush_index();
2842
2843 let scope_a = ImportScope {
2844 file_module: None,
2845 qualified_modules: ["quest_a".to_string()].into_iter().collect(),
2846 bare_imports: BTreeSet::new(),
2847 aliases: BTreeMap::new(),
2848 };
2849 assert_eq!(
2850 lookup_by_name(&index, &scope_a, "ambush", &[SymbolKind::Knot]),
2851 Some(a),
2852 "a file importing quest_a binds quest_a's ambush"
2853 );
2854
2855 let scope_b = ImportScope {
2856 file_module: None,
2857 qualified_modules: ["quest_b".to_string()].into_iter().collect(),
2858 bare_imports: BTreeSet::new(),
2859 aliases: BTreeMap::new(),
2860 };
2861 assert_eq!(
2862 lookup_by_name(&index, &scope_b, "ambush", &[SymbolKind::Knot]),
2863 Some(b),
2864 "a file importing quest_b binds quest_b's ambush — not the flat first-winner"
2865 );
2866 }
2867
2868 /// Issue #1909's fence: [`lookup_unique_by_name`] must only ever answer
2869 /// where [`lookup_by_name`] would answer identically **for every scope
2870 /// whose `file_module` equals the `referrer_module` hint this function
2871 /// was handed** (issue #2233 narrowed the guarantee from "every scope
2872 /// unconditionally" to this — a std-mounted candidate's visibility now
2873 /// depends on that hint agreeing with the scope's own `file_module`; see
2874 /// `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`
2875 /// below for the case that distinguishes them). Both halves are
2876 /// asserted — the sole-candidate name agrees with two deliberately
2877 /// opposed scopes, and the ambiguous name declines.
2878 ///
2879 /// This fixture has no std-mounted candidate; the std-mounted case
2880 /// (issue #2216, the #2197 follow-up this doc used to flag as an
2881 /// un-pinned gap) is covered separately by
2882 /// `unique_lookup_excludes_std_mounted_sole_candidate`,
2883 /// `unique_lookup_skips_std_candidate_and_returns_the_ordinary_one`, and
2884 /// `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`
2885 /// below, now that [`lookup_unique_by_name`] applies the same
2886 /// std-invisibility gate as [`lookup_by_name_direct`], narrowed by a
2887 /// `referrer_module` hint (issue #2233).
2888 #[test]
2889 fn unique_lookup_agrees_with_scoped_lookup() {
2890 let (index, a, b) = two_module_ambush_index();
2891 assert_eq!(
2892 lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
2893 None,
2894 "two same-named candidates: only the scoped lookup can decide, so decline"
2895 );
2896 assert_ne!(a, b, "the fixture must really hold two distinct candidates");
2897
2898 // The same index, filtered to a kind exactly one candidate has:
2899 // now `lookup_by_name`'s own byte-identity fast path returns it
2900 // regardless of scope, so the scope-free answer must match both.
2901 let mut single = SymbolIndex::default();
2902 let (&only_id, only_info) = index
2903 .symbols
2904 .iter()
2905 .find(|(id, _)| **id == a)
2906 .expect("fixture id present");
2907 single.symbols.insert(only_id, only_info.clone());
2908 single.by_name.insert("ambush".to_string(), vec![only_id]);
2909 let scope_a = ImportScope {
2910 file_module: None,
2911 qualified_modules: ["quest_a".to_string()].into_iter().collect(),
2912 bare_imports: BTreeSet::new(),
2913 aliases: BTreeMap::new(),
2914 };
2915 let scope_none = ImportScope {
2916 file_module: None,
2917 qualified_modules: BTreeSet::new(),
2918 bare_imports: BTreeSet::new(),
2919 aliases: BTreeMap::new(),
2920 };
2921 let unique = lookup_unique_by_name(&single, "ambush", &[SymbolKind::Knot], None);
2922 assert_eq!(unique, Some(a));
2923 assert_eq!(
2924 unique,
2925 lookup_by_name(&single, &scope_a, "ambush", &[SymbolKind::Knot])
2926 );
2927 assert_eq!(
2928 unique,
2929 lookup_by_name(&single, &scope_none, "ambush", &[SymbolKind::Knot]),
2930 "the sole-candidate answer must not depend on the scope at all"
2931 );
2932 assert_eq!(
2933 lookup_unique_by_name(&single, "ambush", &[SymbolKind::External], None),
2934 None,
2935 "the kind filter still gates the match"
2936 );
2937 }
2938
2939 #[test]
2940 fn same_module_candidate_wins_over_imported_one() {
2941 let (index, a, b) = two_module_ambush_index();
2942 // A file *inside* quest_b that also imports quest_a: its own module's
2943 // `ambush` is bare-visible and wins over the imported homonym.
2944 let scope = ImportScope {
2945 file_module: Some("quest_b".to_string()),
2946 qualified_modules: ["quest_a".to_string()].into_iter().collect(),
2947 bare_imports: BTreeSet::new(),
2948 aliases: BTreeMap::new(),
2949 };
2950 assert_eq!(
2951 lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
2952 Some(b),
2953 "own-module definition beats an imported homonym"
2954 );
2955 let _ = a;
2956 }
2957
2958 /// Build a `SymbolIndex` with one `Knot` named `ambush` per module in
2959 /// `modules`, all `Public` — the shape [`two_module_ambush_index`] hands
2960 /// M-2d, generalized so a std-shaped module string can sit alongside an
2961 /// ordinary one.
2962 fn ambush_index_with_modules(modules: &[&str]) -> (SymbolIndex, Vec<DefinitionId>) {
2963 use brink_format::DefinitionTag;
2964 let mut index = SymbolIndex::default();
2965 let mut ids = Vec::new();
2966 for (i, module) in modules.iter().enumerate() {
2967 let id = DefinitionId::new(DefinitionTag::Address, 0xA + i as u64);
2968 index.symbols.insert(
2969 id,
2970 SymbolInfo {
2971 kind: SymbolKind::Knot,
2972 file: FileId(0),
2973 range: TextRange::default(),
2974 id,
2975 name: "ambush".to_string(),
2976 params: Vec::new(),
2977 detail: None,
2978 scope: None,
2979 param_detail: None,
2980 module: Some((*module).to_string()),
2981 visibility: Visibility::Public,
2982 },
2983 );
2984 index
2985 .by_name
2986 .entry("ambush".to_string())
2987 .or_default()
2988 .push(id);
2989 ids.push(id);
2990 }
2991 (index, ids)
2992 }
2993
2994 /// Issue #2197, per #2080's SCOPE FENCE (`docs/decision-log.md`,
2995 /// "Stdlib mounts into `Environment`'s manifest at the producer, as
2996 /// plain source"): a std-mounted candidate must be invisible to
2997 /// bare-name resolution — not merely deprioritized — even when it is
2998 /// the *sole* candidate. Before this fix, `lookup_by_name_direct`'s
2999 /// `!multiple` fast path returned any sole candidate unconditionally
3000 /// regardless of scope, which would have let a project silently reach
3001 /// into `std::…` with zero imports.
3002 #[test]
3003 fn std_mounted_sole_candidate_is_invisible_with_no_import() {
3004 let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3005 assert_eq!(
3006 lookup_by_name(
3007 &index,
3008 &ImportScope::default(),
3009 "ambush",
3010 &[SymbolKind::Knot]
3011 ),
3012 None,
3013 "a std-mounted definition must not resolve by bare name with no `use std::…` \
3014 import — reaching it requires an explicit import, which does not exist yet \
3015 (#1582/#2167), so today it must resolve to nothing rather than silently reach std"
3016 );
3017 }
3018
3019 /// The E060 collision shape, at the resolution layer rather than the
3020 /// LIR-lowering self-identity layer `stdlib_mount_no_longer_collides_
3021 /// with_a_projects_own_scene_entered` (brink-test-harness) proves
3022 /// end to end: a project's own declared module and the std mount both
3023 /// declare `ambush`. A file *inside* the project's own module resolves
3024 /// its own `ambush` via the pre-existing `Candidacy::InScope` tier,
3025 /// which already wins the tie-break with or without this issue's std
3026 /// gate — `project_referencing_a_third_module_still_skips_a_coexisting_
3027 /// std_candidate` below is the case that actually distinguishes pre-fix
3028 /// from post-fix behavior for the `Other`/`Other` shape this gate
3029 /// targets. Kept as its own test because the `InScope` tier is a real,
3030 /// separate guarantee worth pinning on its own.
3031 #[test]
3032 fn project_own_module_wins_over_a_coexisting_std_mount_candidate() {
3033 let (index, ids) =
3034 ambush_index_with_modules(&["story::story", "std::conventions::screenplay"]);
3035 let project_id = ids[0];
3036 let scope = ImportScope {
3037 file_module: Some("story::story".to_string()),
3038 qualified_modules: BTreeSet::new(),
3039 bare_imports: BTreeSet::new(),
3040 aliases: BTreeMap::new(),
3041 };
3042 assert_eq!(
3043 lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3044 Some(project_id),
3045 "a file inside `story::story` must resolve its OWN `ambush`, never the coexisting \
3046 std mount's same-named one"
3047 );
3048 }
3049
3050 /// Review finding on #2197: the test above does **not** actually
3051 /// exercise the std-`Other` exclusion — with `file_module ==
3052 /// "story::story"`, the project candidate classifies `Candidacy::
3053 /// InScope` and wins via `first_in_scope` regardless of whether the std
3054 /// gate exists at all (reverting it changes nothing about that test's
3055 /// outcome). This test instead puts the referring file in a **third**
3056 /// declared module, so *neither* candidate is `InScope`: the project's
3057 /// `ambush` classifies `Other` (a real cross-module reference this file
3058 /// has no import for) and the std mount's `ambush` also classifies
3059 /// `Other`. Without the gate, the flat fallback picks whichever `Other`
3060 /// candidate was inserted first in `by_name` — here, the std one,
3061 /// listed first — so this test fails with the gate removed and passes
3062 /// only because the std candidate is skipped before it can ever become
3063 /// `first_match`.
3064 #[test]
3065 fn project_referencing_a_third_module_still_skips_a_coexisting_std_candidate() {
3066 let (index, ids) =
3067 ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
3068 let project_id = ids[1];
3069 let scope = ImportScope {
3070 file_module: Some("story::another_module".to_string()),
3071 qualified_modules: BTreeSet::new(),
3072 bare_imports: BTreeSet::new(),
3073 aliases: BTreeMap::new(),
3074 };
3075 assert_eq!(
3076 lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3077 Some(project_id),
3078 "with neither candidate `InScope`, the std `Other` candidate must still be \
3079 skipped rather than winning the flat first-inserted tie-break"
3080 );
3081 }
3082
3083 /// Issue #2216 (the #2197 follow-up this doc's own "known gap" pointed
3084 /// at), narrowed by #2233: with no `referrer_module` hint at all (`None`
3085 /// — the shape every pre-#2233 caller effectively had), a std-mounted
3086 /// candidate must still be unconditionally invisible here, exactly as it
3087 /// is to [`lookup_by_name`] with the default (no-import) scope, even
3088 /// when it is the function's *sole* candidate — the case the old
3089 /// `!multiple` style fast path would otherwise return unconditionally.
3090 #[test]
3091 fn unique_lookup_excludes_std_mounted_sole_candidate() {
3092 let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3093 assert_eq!(
3094 lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
3095 None,
3096 "a std-mounted sole candidate must not resolve through the scope-free path when \
3097 the caller has no referrer-module hint — lookup_by_name returns None for it under \
3098 the default scope, so lookup_unique_by_name must agree rather than silently \
3099 reaching into std with no import"
3100 );
3101 }
3102
3103 /// The unification half of #2216: with a std-mounted candidate
3104 /// coexisting alongside one ordinary candidate, [`lookup_unique_by_name`]
3105 /// must still resolve the ordinary one (not decline as ambiguous, and
3106 /// not pick the std one) — agreeing with [`lookup_by_name`] for a scope
3107 /// where neither candidate is `InScope` (asserted below with
3108 /// `file_module = "story::another_module"`, passed through as
3109 /// `referrer_module`). That is not the *only* scope where the two
3110 /// agree — a scope with `file_module = "story::story"` (the ordinary
3111 /// candidate's own module) also agrees, since the ordinary candidate
3112 /// then classifies `InScope` and wins [`lookup_by_name`]'s own
3113 /// tie-break. The std-referrer case — once the one deliberate
3114 /// disagreement this doc used to call out — is now covered separately
3115 /// by `unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module`
3116 /// below, now that issue #2233 threads a `referrer_module` hint through.
3117 #[test]
3118 fn unique_lookup_skips_std_candidate_and_returns_the_ordinary_one() {
3119 let (index, ids) =
3120 ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
3121 let project_id = ids[1];
3122 assert_eq!(
3123 lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], None),
3124 Some(project_id),
3125 "the std candidate must be excluded from the sole-match count entirely, leaving \
3126 the one ordinary candidate as the unique match"
3127 );
3128 let other_scope = ImportScope {
3129 file_module: Some("story::another_module".to_string()),
3130 qualified_modules: BTreeSet::new(),
3131 bare_imports: BTreeSet::new(),
3132 aliases: BTreeMap::new(),
3133 };
3134 assert_eq!(
3135 lookup_unique_by_name(
3136 &index,
3137 "ambush",
3138 &[SymbolKind::Knot],
3139 Some("story::another_module")
3140 ),
3141 lookup_by_name(&index, &other_scope, "ambush", &[SymbolKind::Knot]),
3142 "the scope-free answer must agree with the scoped one for a scope where neither \
3143 candidate is InScope"
3144 );
3145 let project_scope = ImportScope {
3146 file_module: Some("story::story".to_string()),
3147 qualified_modules: BTreeSet::new(),
3148 bare_imports: BTreeSet::new(),
3149 aliases: BTreeMap::new(),
3150 };
3151 assert_eq!(
3152 lookup_unique_by_name(&index, "ambush", &[SymbolKind::Knot], Some("story::story")),
3153 lookup_by_name(&index, &project_scope, "ambush", &[SymbolKind::Knot]),
3154 "the scope-free answer must also agree with the scoped one for a scope where the \
3155 ordinary candidate (not the std one) is InScope"
3156 );
3157 }
3158
3159 /// Issue #2233: the fix for the one deliberate disagreement
3160 /// `unique_lookup_excludes_std_mounted_sole_candidate`'s old sibling doc
3161 /// used to describe (the pre-fix version of this file documented it on
3162 /// `unique_lookup_skips_std_candidate_and_returns_the_ordinary_one`). A
3163 /// referrer whose own `file_module` IS the std module keeps resolving
3164 /// the std candidate via [`lookup_by_name_direct`]'s `InScope` tier
3165 /// (std's own internal references are untouched by the #2197/#2216
3166 /// gates) — and now that `lookup_unique_by_name` is handed that same
3167 /// module string as `referrer_module`, it reproduces the identical
3168 /// answer instead of excluding the std candidate unconditionally, for
3169 /// the case that matters: the std candidate is the *sole* match once
3170 /// visible (no coexisting ordinary candidate of the same name — see
3171 /// `unique_lookup_still_declines_when_a_visible_std_sibling_is_ambiguous`
3172 /// for what happens when one does coexist).
3173 #[test]
3174 fn unique_lookup_reproduces_in_scope_std_sibling_with_referrer_module() {
3175 let (index, ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3176 let std_id = ids[0];
3177 let std_scope = ImportScope {
3178 file_module: Some("std::conventions::screenplay".to_string()),
3179 qualified_modules: BTreeSet::new(),
3180 bare_imports: BTreeSet::new(),
3181 aliases: BTreeMap::new(),
3182 };
3183 assert_eq!(
3184 lookup_by_name(&index, &std_scope, "ambush", &[SymbolKind::Knot]),
3185 Some(std_id),
3186 "a referrer whose own file_module IS the std module keeps resolving the std \
3187 candidate via lookup_by_name_direct's InScope tier — std's own internal \
3188 references are untouched by the #2197/#2216 gates"
3189 );
3190 assert_eq!(
3191 lookup_unique_by_name(
3192 &index,
3193 "ambush",
3194 &[SymbolKind::Knot],
3195 Some("std::conventions::screenplay")
3196 ),
3197 lookup_by_name(&index, &std_scope, "ambush", &[SymbolKind::Knot]),
3198 "with the referrer's own module threaded through, lookup_unique_by_name now agrees \
3199 with lookup_by_name for a referrer inside the std tree looking up a std sibling — \
3200 the #2233 fix"
3201 );
3202 }
3203
3204 /// Issue #2249: `resolve_type_ref` (the new `RefKind::Type` arm) routes
3205 /// a TM-2 annotation through this file's own `lookup_by_name` — full
3206 /// `ImportScope`/`Candidacy` semantics — rather than
3207 /// `lir::lower::decls::lookup_global`'s narrower fallback, which
3208 /// `ShapeTable::resolve` used before this issue (deleted; see
3209 /// `brink-ir::lir::lower::structs`'s module doc). The two primitives
3210 /// disagree on exactly this shape: a referrer *inside* a std module
3211 /// referencing a **sibling** struct in the *same* std module, declared
3212 /// in a different file, with no explicit import — `lookup_global`
3213 /// excludes every std-declared candidate unconditionally in its
3214 /// fallback arm (no referrer-is-std carve-out, `decls.rs`'s own doc),
3215 /// so this would have resolved to `None` under the old
3216 /// `ShapeTable::resolve`; `lookup_by_name_direct`'s `InScope` tier
3217 /// (std's own internal references are untouched by the #2197/#2216
3218 /// gates, same as the `Knot` case `unique_lookup_reproduces_in_scope_
3219 /// std_sibling_with_referrer_module` above proves) resolves it. This is
3220 /// the one behavioral delta issue #2249's PR body must call out: a
3221 /// std convention file's own `~ temp c: Cue`-shaped annotation
3222 /// referencing a *sibling* std file's struct now gets the static-offset
3223 /// chase (`known_shape`) it silently lost before.
3224 #[test]
3225 fn resolve_type_ref_reproduces_in_scope_std_sibling_with_referrer_module() {
3226 let mut index = SymbolIndex::default();
3227 let cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC0E);
3228 index.symbols.insert(
3229 cue_id,
3230 SymbolInfo {
3231 kind: SymbolKind::Struct,
3232 file: FileId(9),
3233 range: TextRange::default(),
3234 id: cue_id,
3235 name: "Cue".to_string(),
3236 params: Vec::new(),
3237 detail: None,
3238 scope: None,
3239 param_detail: None,
3240 module: Some("std::conventions::screenplay".to_string()),
3241 visibility: Visibility::Public,
3242 },
3243 );
3244 index
3245 .by_name
3246 .entry("Cue".to_string())
3247 .or_default()
3248 .push(cue_id);
3249
3250 let std_scope = ImportScope {
3251 file_module: Some("std::conventions::screenplay".to_string()),
3252 qualified_modules: BTreeSet::new(),
3253 bare_imports: BTreeSet::new(),
3254 aliases: BTreeMap::new(),
3255 };
3256 let referrer_file = FileId(1); // a *different* std file, same module
3257 let uref = UnresolvedRef {
3258 path: "Cue".to_string(),
3259 range: range(0, 3),
3260 kind: RefKind::Type,
3261 scope: Scope::default(),
3262 arg_count: None,
3263 module_qualified: false,
3264 };
3265 let mut map: ResolutionMap = Vec::new();
3266 resolve_type_ref(&index, &std_scope, referrer_file, &uref, &mut map);
3267
3268 assert_eq!(
3269 map,
3270 vec![ResolvedRef {
3271 file: referrer_file,
3272 range: uref.range,
3273 target: cue_id,
3274 }],
3275 "a referrer inside std referencing a sibling std file's struct with no import \
3276 resolves via lookup_by_name_direct's InScope tier — the exact case \
3277 ShapeTable::resolve's old lookup_global-based fallback could never reach \
3278 (it excluded every std-declared candidate unconditionally, referrer or not)"
3279 );
3280 }
3281
3282 /// Issue #2249: the flip side of the test above — a scalar/tower/
3283 /// generic-head keyword (never a declared struct) must resolve to
3284 /// nothing and raise no diagnostic, because `resolve_type_ref`'s own
3285 /// doc is explicit that "no declared struct named this" is the
3286 /// legal, common case for a `RefKind::Type` reference, not an error.
3287 #[test]
3288 fn resolve_type_ref_silently_misses_a_scalar_keyword_name() {
3289 let index = SymbolIndex::default();
3290 let uref = UnresolvedRef {
3291 path: "int".to_string(),
3292 range: range(0, 3),
3293 kind: RefKind::Type,
3294 scope: Scope::default(),
3295 arg_count: None,
3296 module_qualified: false,
3297 };
3298 let mut map: ResolutionMap = Vec::new();
3299 resolve_type_ref(&index, &ImportScope::default(), FileId(0), &uref, &mut map);
3300 assert!(
3301 map.is_empty(),
3302 "`int` never names a declared STRUCT — this must not resolve, and (unlike \
3303 RefKind::Struct's E068) resolve_type_ref never diagnoses a miss either, since a \
3304 miss here is not necessarily wrong"
3305 );
3306 }
3307
3308 /// PR #2271 review finding: `lir::lower::structs`'s own tests
3309 /// (`lookup_global_excludes_a_sole_std_declared_struct_with_no_project_
3310 /// homonym`, `lookup_global_picks_the_referrers_own_shape_when_names_
3311 /// collide`) were retargeted at `decls::lookup_global` after issue
3312 /// #2249 deleted `ShapeTable::resolve` — but annotations no longer call
3313 /// `lookup_global` at all; they go through this file's own
3314 /// `resolve_type_ref`/`lookup_by_name`/`ImportScope` machinery (this
3315 /// module's own doc on `resolve_type_ref`). Neither retargeted test, nor
3316 /// `resolve_type_ref_silently_misses_a_scalar_keyword_name` above (an
3317 /// empty index, no std/project homonym in play at all), exercises the
3318 /// std-exclusion property through the real annotation path. This test
3319 /// closes that gap's negative half: a struct only a mounted std module
3320 /// declares, referenced from an ordinary (non-std, non-importing) project
3321 /// file, must not resolve — mirroring
3322 /// `std_mounted_sole_candidate_is_invisible_with_no_import` above, but
3323 /// through `resolve_type_ref` (the real TM-2 annotation path) instead of
3324 /// `lookup_by_name` directly.
3325 #[test]
3326 fn resolve_type_ref_excludes_a_std_only_struct_with_no_project_homonym_or_import() {
3327 let mut index = SymbolIndex::default();
3328 let cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC0F);
3329 index.symbols.insert(
3330 cue_id,
3331 SymbolInfo {
3332 kind: SymbolKind::Struct,
3333 file: FileId(9),
3334 range: TextRange::default(),
3335 id: cue_id,
3336 name: "Cue".to_string(),
3337 params: Vec::new(),
3338 detail: None,
3339 scope: None,
3340 param_detail: None,
3341 module: Some("std::conventions::screenplay".to_string()),
3342 visibility: Visibility::Public,
3343 },
3344 );
3345 index
3346 .by_name
3347 .entry("Cue".to_string())
3348 .or_default()
3349 .push(cue_id);
3350
3351 // An ordinary project file, no `#@module`/`use` in play at all —
3352 // the default scope every non-modules file carries.
3353 let uref = UnresolvedRef {
3354 path: "Cue".to_string(),
3355 range: range(0, 3),
3356 kind: RefKind::Type,
3357 scope: Scope::default(),
3358 arg_count: None,
3359 module_qualified: false,
3360 };
3361 let mut map: ResolutionMap = Vec::new();
3362 resolve_type_ref(&index, &ImportScope::default(), FileId(0), &uref, &mut map);
3363 assert!(
3364 map.is_empty(),
3365 "a struct only a mounted std module declares must not resolve for a `~ temp c: Cue`- \
3366 shaped annotation with no project-side homonym and no import — the sole-candidate \
3367 std-exclusion property `resolve_type_ref`'s own doc claims, unproven by any test \
3368 through this path before: {map:?}"
3369 );
3370 }
3371
3372 /// PR #2271 review finding, positive half: a project's own struct and a
3373 /// coexisting mounted std struct sharing a bare name (M-2d, issue
3374 /// #2238) — a referrer *inside* the project's own declared module must
3375 /// resolve its own struct through `resolve_type_ref`, never the std
3376 /// mount's same-named one. Mirrors `project_own_module_wins_over_a_
3377 /// coexisting_std_mount_candidate` above, but through the real
3378 /// annotation path.
3379 #[test]
3380 fn resolve_type_ref_picks_the_referrers_own_project_struct_over_a_coexisting_std_homonym() {
3381 let mut index = SymbolIndex::default();
3382 let std_cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC10);
3383 index.symbols.insert(
3384 std_cue_id,
3385 SymbolInfo {
3386 kind: SymbolKind::Struct,
3387 file: FileId(9),
3388 range: TextRange::default(),
3389 id: std_cue_id,
3390 name: "Cue".to_string(),
3391 params: Vec::new(),
3392 detail: None,
3393 scope: None,
3394 param_detail: None,
3395 module: Some("std::conventions::screenplay".to_string()),
3396 visibility: Visibility::Public,
3397 },
3398 );
3399 let project_cue_id = DefinitionId::new(brink_format::DefinitionTag::StructDef, 0xC11);
3400 index.symbols.insert(
3401 project_cue_id,
3402 SymbolInfo {
3403 kind: SymbolKind::Struct,
3404 file: FileId(1),
3405 range: TextRange::default(),
3406 id: project_cue_id,
3407 name: "Cue".to_string(),
3408 params: Vec::new(),
3409 detail: None,
3410 scope: None,
3411 param_detail: None,
3412 module: Some("story::market".to_string()),
3413 visibility: Visibility::Public,
3414 },
3415 );
3416 for id in [std_cue_id, project_cue_id] {
3417 index.by_name.entry("Cue".to_string()).or_default().push(id);
3418 }
3419
3420 let scope = ImportScope {
3421 file_module: Some("story::market".to_string()),
3422 qualified_modules: BTreeSet::new(),
3423 bare_imports: BTreeSet::new(),
3424 aliases: BTreeMap::new(),
3425 };
3426 let uref = UnresolvedRef {
3427 path: "Cue".to_string(),
3428 range: range(0, 3),
3429 kind: RefKind::Type,
3430 scope: Scope::default(),
3431 arg_count: None,
3432 module_qualified: false,
3433 };
3434 let mut map: ResolutionMap = Vec::new();
3435 resolve_type_ref(&index, &scope, FileId(1), &uref, &mut map);
3436 assert_eq!(
3437 map,
3438 vec![ResolvedRef {
3439 file: FileId(1),
3440 range: uref.range,
3441 target: project_cue_id,
3442 }],
3443 "a `story::market` file's own `~ temp c: Cue` must resolve to `story::market`'s own \
3444 Cue, never the coexisting std mount's same-named one: {map:?}"
3445 );
3446 }
3447
3448 /// Issue #2233: threading `referrer_module` through fixes the *sole
3449 /// candidate* disagreement above, but does not (and cannot, without
3450 /// replicating `lookup_by_name_direct`'s full `InScope`-beats-`Other`
3451 /// tie-break, a strictly larger change than a referrer hint) make
3452 /// `lookup_unique_by_name` reproduce [`lookup_by_name`]'s tie-break when
3453 /// an ordinary same-name candidate coexists with the now-visible std
3454 /// sibling. There, un-excluding the std candidate makes it a *second*
3455 /// candidate rather than the resolved one, so this function declines
3456 /// (`None`) exactly per its own documented contract ("when it returns
3457 /// `None` on an ambiguous name, the caller must fall back... rather than
3458 /// guess") — a safe decline, not the silently-wrong `Some(project_id)`
3459 /// answer this exact fixture produced before #2233 (the referrer-module
3460 /// hint being `None` in every pre-#2233 call site is what caused that:
3461 /// the std candidate was excluded unconditionally, leaving the ordinary
3462 /// one as a false "sole" match).
3463 #[test]
3464 fn unique_lookup_still_declines_when_a_visible_std_sibling_is_ambiguous() {
3465 let (index, _ids) =
3466 ambush_index_with_modules(&["std::conventions::screenplay", "story::story"]);
3467 assert_eq!(
3468 lookup_unique_by_name(
3469 &index,
3470 "ambush",
3471 &[SymbolKind::Knot],
3472 Some("std::conventions::screenplay")
3473 ),
3474 None,
3475 "with the std candidate now visible (referrer inside its own module) alongside a \
3476 coexisting ordinary candidate, the name is genuinely ambiguous to this scope-free \
3477 function — it must decline rather than silently pick either one"
3478 );
3479 }
3480
3481 /// Issue #2233: a referrer *inside* std but in a *different* std module
3482 /// than the candidate must not gain visibility either — only an exact
3483 /// module match reproduces `InScope`; a cross-std-submodule reference is
3484 /// `Other` under `classify` (no import machinery to promote it to
3485 /// `Imported` here), so it stays excluded exactly like any other
3486 /// cross-module std reference.
3487 #[test]
3488 fn unique_lookup_still_excludes_a_different_std_sibling_module() {
3489 let (index, ids) =
3490 ambush_index_with_modules(&["std::conventions::screenplay", "std::conventions::other"]);
3491 let other_id = ids[1];
3492 assert_eq!(
3493 lookup_unique_by_name(
3494 &index,
3495 "ambush",
3496 &[SymbolKind::Knot],
3497 Some("std::conventions::other")
3498 ),
3499 Some(other_id),
3500 "the referrer's own std module's candidate still resolves (InScope, exact match)"
3501 );
3502 let screenplay_scope = ImportScope {
3503 file_module: Some("std::conventions::other".to_string()),
3504 qualified_modules: BTreeSet::new(),
3505 bare_imports: BTreeSet::new(),
3506 aliases: BTreeMap::new(),
3507 };
3508 assert_eq!(
3509 lookup_by_name(&index, &screenplay_scope, "ambush", &[SymbolKind::Knot]),
3510 Some(other_id),
3511 "sanity: lookup_by_name agrees — the referrer's own module wins, not the sibling"
3512 );
3513 }
3514
3515 /// Issue #2217: `is_std_module`/the std-invisibility gate excludes a
3516 /// name from bare-name resolution identically whether the candidate is
3517 /// the *embedded* stdlib mount or a project's own file that legitimately
3518 /// lives at a `std/…` path (`mount_stdlib`'s "project source at the same
3519 /// key wins" carve-out — both mint the same `std::…` module identity,
3520 /// by design, per #2245's "peer roots" ruling). Before this fix, the
3521 /// resulting diagnostic was a bare "unresolved name" with nothing
3522 /// distinguishing "no such symbol anywhere" from "this symbol exists,
3523 /// but only under `std::`, invisible by rule" — an author who names
3524 /// their own directory `std/` had no way to learn that from the
3525 /// diagnostic alone.
3526 #[test]
3527 fn is_std_shadowed_name_true_when_only_a_std_candidate_exists() {
3528 let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3529 assert!(
3530 is_std_shadowed_name(&index, "ambush"),
3531 "a name whose sole declaration lives under the std peer root must be reported as \
3532 std-shadowed"
3533 );
3534 }
3535
3536 #[test]
3537 fn is_std_shadowed_name_false_for_an_ordinary_project_name() {
3538 let (index, _ids) = ambush_index_with_modules(&["story::story"]);
3539 assert!(
3540 !is_std_shadowed_name(&index, "ambush"),
3541 "a name declared only in an ordinary project module must not be reported as \
3542 std-shadowed"
3543 );
3544 assert!(
3545 !is_std_shadowed_name(&index, "no_such_name"),
3546 "a name with no declaration at all must not be reported as std-shadowed either"
3547 );
3548 }
3549
3550 #[test]
3551 fn unresolved_diag_hints_at_std_shadowing_when_a_std_candidate_exists() {
3552 let (index, _ids) = ambush_index_with_modules(&["std::conventions::screenplay"]);
3553 let diag = unresolved_diag(
3554 &index,
3555 &ImportScope::default(),
3556 FileId(0),
3557 range(0, 6),
3558 "ambush",
3559 DiagnosticCode::E025,
3560 &[],
3561 );
3562 assert_eq!(diag.code, DiagnosticCode::E025);
3563 assert!(
3564 diag.message.contains("std::") && diag.message.contains("peer root"),
3565 "the diagnostic for a name that IS declared, but only under std, must say so \
3566 rather than reading as an ordinary unresolved-name error: {}",
3567 diag.message
3568 );
3569 }
3570
3571 #[test]
3572 fn unresolved_diag_stays_plain_when_no_std_candidate_exists() {
3573 let index = SymbolIndex::default();
3574 let diag = unresolved_diag(
3575 &index,
3576 &ImportScope::default(),
3577 FileId(0),
3578 range(0, 6),
3579 "nope",
3580 DiagnosticCode::E025,
3581 &[],
3582 );
3583 assert_eq!(
3584 diag.message,
3585 format!("{}: `nope`", DiagnosticCode::E025.title()),
3586 "a genuinely unresolved name (no std candidate anywhere) must keep the plain \
3587 message — the hint must not fire spuriously"
3588 );
3589 }
3590
3591 /// End-to-end through the real resolution entry point, not just the
3592 /// diagnostic-formatting helper directly: a project file that places its
3593 /// own `Variable` declaration under a `std/…` path (the exact landmine
3594 /// #2217 describes — nothing marks this as the embedded stdlib) is
3595 /// invisible to a bare reference from the rest of the project, and the
3596 /// `E025` this produces must carry the std-shadowing hint.
3597 #[test]
3598 fn resolve_variable_hints_std_shadowing_for_a_projects_own_std_path_file() {
3599 let mut index = SymbolIndex::default();
3600 let id = DefinitionId::new(brink_format::DefinitionTag::Address, 0xF00D);
3601 index.symbols.insert(
3602 id,
3603 SymbolInfo {
3604 kind: SymbolKind::Variable,
3605 file: FileId(0),
3606 range: TextRange::default(),
3607 id,
3608 name: "screenplay_intro".to_string(),
3609 params: Vec::new(),
3610 detail: None,
3611 scope: None,
3612 param_detail: None,
3613 module: Some("std::conventions::screenplay".to_string()),
3614 visibility: Visibility::Public,
3615 },
3616 );
3617 index
3618 .by_name
3619 .entry("screenplay_intro".to_string())
3620 .or_default()
3621 .push(id);
3622
3623 let scope = ImportScope::default();
3624 let locals: Vec<LocalSymbol> = Vec::new();
3625 let mut map: ResolutionMap = Vec::new();
3626 let mut diagnostics = Vec::new();
3627 let uref = uref("screenplay_intro", RefKind::Variable, None, None);
3628
3629 resolve_variable(
3630 &index,
3631 &scope,
3632 &locals,
3633 FileId(1),
3634 &uref,
3635 &mut map,
3636 &mut diagnostics,
3637 );
3638
3639 assert!(map.is_empty(), "a std-shadowed candidate must not resolve");
3640 assert_eq!(diagnostics.len(), 1);
3641 assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
3642 assert!(
3643 diagnostics[0].message.contains("std::"),
3644 "the real resolution path's E025 must carry the std-shadowing hint too, not just \
3645 the diagnostic-formatting helper in isolation: {}",
3646 diagnostics[0].message
3647 );
3648 }
3649
3650 #[test]
3651 fn default_scope_falls_back_to_flat_first_winner() {
3652 // Byte-identity guard: with no import context (the pre-M-2d world),
3653 // a multi-candidate lookup returns the flat first-inserted winner,
3654 // exactly as the old flat resolver did.
3655 let (index, a, _b) = two_module_ambush_index();
3656 assert_eq!(
3657 lookup_by_name(
3658 &index,
3659 &ImportScope::default(),
3660 "ambush",
3661 &[SymbolKind::Knot]
3662 ),
3663 Some(a),
3664 "no imports → flat first-winner, unchanged from pre-M-2d"
3665 );
3666 }
3667
3668 /// `ImportScope` granularity regression (issue #790 review): a bare
3669 /// import must be name-precise, matching `modules::import_covers`
3670 /// exactly. `ImportScope::new` used to collapse every import to just its
3671 /// module name (`imports.iter().map(|i| i.module.clone())`), so a bare
3672 /// `IMPORT { other } FROM quest_a` wrongly counted as importing *all* of
3673 /// `quest_a` — including its unrelated public `ambush`.
3674 #[test]
3675 fn bare_import_grants_candidacy_only_for_its_own_named_item() {
3676 let (index, _a, b) = two_module_ambush_index();
3677 let scope = ImportScope::new(
3678 None,
3679 &[
3680 Import {
3681 module: "quest_a".to_string(),
3682 module_range: TextRange::default(),
3683 items: vec![ImportItem {
3684 name: "other".to_string(),
3685 alias: None,
3686 range: TextRange::default(),
3687 }],
3688 bare: true,
3689 range: TextRange::default(),
3690 },
3691 Import {
3692 module: "quest_b".to_string(),
3693 module_range: TextRange::default(),
3694 items: vec![ImportItem {
3695 name: "ambush".to_string(),
3696 alias: None,
3697 range: TextRange::default(),
3698 }],
3699 bare: true,
3700 range: TextRange::default(),
3701 },
3702 ],
3703 );
3704 assert_eq!(
3705 lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3706 Some(b),
3707 "bare-importing `other` from quest_a must not license quest_a's `ambush` — \
3708 only quest_b's `ambush` (actually bare-imported) is a candidate"
3709 );
3710 }
3711
3712 /// A qualified `IMPORT mod` still licenses every public export of that
3713 /// module (unlike a bare import, which is name-precise) — the
3714 /// granularity fix must not regress this path.
3715 #[test]
3716 fn qualified_import_still_grants_candidacy_for_any_export() {
3717 let (index, a, _b) = two_module_ambush_index();
3718 let scope = ImportScope::new(
3719 None,
3720 &[Import {
3721 module: "quest_a".to_string(),
3722 module_range: TextRange::default(),
3723 items: Vec::new(),
3724 bare: false,
3725 range: TextRange::default(),
3726 }],
3727 );
3728 assert_eq!(
3729 lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3730 Some(a),
3731 "a qualified `IMPORT quest_a` still licenses quest_a's `ambush`"
3732 );
3733 }
3734
3735 // ── Import aliasing (issue #1590) ───────────────────────────────
3736
3737 /// The headline bug: `IMPORT { ambush AS b } FROM quest_a` must make `b`
3738 /// resolve — before the fix, `ImportItem.alias` was read only by the
3739 /// E089 duplicate check, so a reference to the alias found nothing in
3740 /// `index.by_name` (keyed by definitions' own spellings only) and
3741 /// resolution silently failed.
3742 #[test]
3743 fn aliased_bare_import_resolves_via_its_local_alias() {
3744 let (index, a, _b) = two_module_ambush_index();
3745 let scope = ImportScope::new(
3746 None,
3747 &[Import {
3748 module: "quest_a".to_string(),
3749 module_range: TextRange::default(),
3750 items: vec![ImportItem {
3751 name: "ambush".to_string(),
3752 alias: Some("b".to_string()),
3753 range: TextRange::default(),
3754 }],
3755 bare: true,
3756 range: TextRange::default(),
3757 }],
3758 );
3759 assert_eq!(
3760 lookup_by_name(&index, &scope, "b", &[SymbolKind::Knot]),
3761 Some(a),
3762 "`ambush AS b` must make `b` resolve to quest_a's `ambush`"
3763 );
3764 }
3765
3766 /// Additive ruling (issue #1590 — "is the original name still
3767 /// licensed?"): brink's alias is additive, not Rust's shadow-and-revoke.
3768 /// The source spelling stays resolvable through the very same import —
3769 /// see the doc comment on [`lookup_by_name`] for the full justification
3770 /// (the fast path's byte-identity guarantee already ignores `ImportScope`
3771 /// for a globally-unique name, so a strict revoke would only sometimes
3772 /// hold).
3773 #[test]
3774 fn aliased_bare_import_also_still_resolves_via_its_original_name() {
3775 let (index, a, _b) = two_module_ambush_index();
3776 let scope = ImportScope::new(
3777 None,
3778 &[Import {
3779 module: "quest_a".to_string(),
3780 module_range: TextRange::default(),
3781 items: vec![ImportItem {
3782 name: "ambush".to_string(),
3783 alias: Some("b".to_string()),
3784 range: TextRange::default(),
3785 }],
3786 bare: true,
3787 range: TextRange::default(),
3788 }],
3789 );
3790 assert_eq!(
3791 lookup_by_name(&index, &scope, "ambush", &[SymbolKind::Knot]),
3792 Some(a),
3793 "the source name `ambush` must still resolve alongside its alias `b`"
3794 );
3795 }
3796
3797 /// Negative case: an alias is scoped to the exact `(module, kind)` its
3798 /// import named — it must never resolve against a same-named symbol of a
3799 /// different kind, nor leak into a file that never declared it.
3800 #[test]
3801 fn alias_does_not_resolve_the_wrong_kind() {
3802 let (index, _a, _b) = two_module_ambush_index();
3803 let scope = ImportScope::new(
3804 None,
3805 &[Import {
3806 module: "quest_a".to_string(),
3807 module_range: TextRange::default(),
3808 items: vec![ImportItem {
3809 name: "ambush".to_string(),
3810 alias: Some("b".to_string()),
3811 range: TextRange::default(),
3812 }],
3813 bare: true,
3814 range: TextRange::default(),
3815 }],
3816 );
3817 assert_eq!(
3818 lookup_by_name(&index, &scope, "b", &[SymbolKind::Variable]),
3819 None,
3820 "`b` aliases a Knot; it must not resolve when a Variable is requested"
3821 );
3822 }
3823
3824 /// Negative case: a name that is neither imported nor aliased in this
3825 /// file's scope must not resolve just because *some* file's `ImportScope`
3826 /// carries an alias for it — `lookup_by_name` is per-file.
3827 #[test]
3828 fn unrelated_scope_has_no_alias_and_does_not_resolve() {
3829 let (index, _a, _b) = two_module_ambush_index();
3830 assert_eq!(
3831 lookup_by_name(&index, &ImportScope::default(), "b", &[SymbolKind::Knot]),
3832 None,
3833 "a file with no import scope must never resolve an alias it never declared"
3834 );
3835 }
3836
3837 /// Precedence when an alias collides with an in-scope direct name (see
3838 /// the doc comment on [`lookup_by_name`]): `lookup_by_name_direct` always
3839 /// runs first, so a local knot named `start` wins over an alias `start`
3840 /// that a bare import bound to a *different* knot — the alias fallback
3841 /// only ever fires once the direct lookup comes up empty. `IMPORT {
3842 /// haggle AS start } FROM quest_a` in a file that also defines `start`
3843 /// silently loses the alias to the local definition.
3844 #[test]
3845 fn alias_colliding_with_an_in_scope_direct_name_resolves_to_the_direct_name() {
3846 use brink_format::DefinitionTag;
3847 let mut index = SymbolIndex::default();
3848 let local_start = DefinitionId::new(DefinitionTag::Address, 0x51A47);
3849 index.symbols.insert(
3850 local_start,
3851 SymbolInfo {
3852 kind: SymbolKind::Knot,
3853 file: FileId(0),
3854 range: TextRange::default(),
3855 id: local_start,
3856 name: "start".to_string(),
3857 params: Vec::new(),
3858 detail: None,
3859 scope: None,
3860 param_detail: None,
3861 module: None,
3862 visibility: Visibility::Public,
3863 },
3864 );
3865 index
3866 .by_name
3867 .entry("start".to_string())
3868 .or_default()
3869 .push(local_start);
3870
3871 let (ambush_index, aliased_target, _b) = two_module_ambush_index();
3872 for (name, ids) in ambush_index.by_name {
3873 index.by_name.entry(name).or_default().extend(ids);
3874 }
3875 for (id, info) in ambush_index.symbols {
3876 index.symbols.insert(id, info);
3877 }
3878
3879 let scope = ImportScope::new(
3880 None,
3881 &[Import {
3882 module: "quest_a".to_string(),
3883 module_range: TextRange::default(),
3884 items: vec![ImportItem {
3885 name: "ambush".to_string(),
3886 alias: Some("start".to_string()),
3887 range: TextRange::default(),
3888 }],
3889 bare: true,
3890 range: TextRange::default(),
3891 }],
3892 );
3893
3894 assert_eq!(
3895 lookup_by_name(&index, &scope, "start", &[SymbolKind::Knot]),
3896 Some(local_start),
3897 "a direct in-scope `start` must win over the colliding alias — \
3898 `ambush AS start` never reaches quest_a's ambush ({aliased_target:?}) \
3899 under that name"
3900 );
3901 }
3902
3903 // ── Issue #2287: module-qualified divert resolution ──────────────
3904 //
3905 // `market/barter.brink` declaring `pub flow haggle()` (module
3906 // `story::market::barter`), referenced from `story.brink`. Four rows,
3907 // the maintainer's corrected model (issue #2287's comment):
3908 //
3909 // | import | `-> barter::haggle` | `-> haggle` |
3910 // |-------------------------------------|----------------------|-------------|
3911 // | *(none)* | rejected | rejected |
3912 // | `use story::market::barter;` | accepted | rejected |
3913 // | `use story::market::barter::haggle;`| — | accepted |
3914 //
3915 // The fourth row (`use story::market::barter::*;`, a glob import) is
3916 // not exercised here — the native grammar has no glob-`use` production
3917 // at all (`brink_syntax_native::parser::decl::use_tree` accepts only an
3918 // `IDENT` segment, a `{ … }` group, or a trailing `as` alias; no `*`
3919 // arm), so that import spelling cannot be constructed as HIR in the
3920 // first place. See this PR's body for the full finding.
3921
3922 fn haggle_index(module: &str) -> (SymbolIndex, DefinitionId) {
3923 use brink_format::DefinitionTag;
3924 let mut index = SymbolIndex::default();
3925 let id = DefinitionId::new(DefinitionTag::Address, 0x748);
3926 index.symbols.insert(
3927 id,
3928 SymbolInfo {
3929 kind: SymbolKind::Knot,
3930 file: FileId(1),
3931 range: TextRange::default(),
3932 id,
3933 name: "haggle".to_string(),
3934 params: Vec::new(),
3935 detail: None,
3936 scope: None,
3937 param_detail: None,
3938 module: Some(module.to_string()),
3939 visibility: Visibility::Public,
3940 },
3941 );
3942 index
3943 .by_name
3944 .entry("haggle".to_string())
3945 .or_default()
3946 .push(id);
3947 (index, id)
3948 }
3949
3950 fn divert_uref(path: &str, module_qualified: bool) -> UnresolvedRef {
3951 UnresolvedRef {
3952 path: path.to_string(),
3953 range: range(0, path.len() as u32),
3954 kind: RefKind::Divert,
3955 scope: Scope::default(),
3956 arg_count: None,
3957 module_qualified,
3958 }
3959 }
3960
3961 /// Bug (a): `use story::market::barter;` (a module-qualified import —
3962 /// lowered as a bare import of item `barter` from `story::market`, whose
3963 /// #1592 dual-reading also licenses `story::market::barter` as a
3964 /// qualified module candidate) must accept `-> barter::haggle`.
3965 ///
3966 /// Reverting `lookup_divert`'s `uref.module_qualified` branch makes this
3967 /// fail: the old code only ever tried `path.contains('.')`, and a
3968 /// `::`-joined path with no dot at all falls through every branch to
3969 /// `None` — reproducing the exact reported defect (`unresolved divert
3970 /// target: barter.haggle`, before this fix normalized `::` to `.` and
3971 /// lost the distinction entirely).
3972 #[test]
3973 fn qualified_divert_resolves_via_module_qualified_import() {
3974 let (index, haggle_id) = haggle_index("story::market::barter");
3975 let scope = ImportScope::new(
3976 None,
3977 &[Import {
3978 module: "story::market".to_string(),
3979 module_range: TextRange::default(),
3980 items: vec![ImportItem {
3981 name: "barter".to_string(),
3982 alias: None,
3983 range: TextRange::default(),
3984 }],
3985 bare: true,
3986 range: TextRange::default(),
3987 }],
3988 );
3989 let uref = divert_uref("barter::haggle", true);
3990 assert_eq!(
3991 lookup_divert(&index, &scope, &[], &uref),
3992 Some(haggle_id),
3993 "`use story::market::barter;` must license the module-qualified \
3994 `-> barter::haggle` divert"
3995 );
3996 }
3997
3998 /// The other half of bug (a): with no import at all, `-> barter::haggle`
3999 /// must stay rejected — and the diagnostic must spell the qualifier with
4000 /// `::`, not the confusing `.` the pre-fix path-joining produced.
4001 #[test]
4002 fn qualified_divert_rejected_with_no_import_and_message_uses_double_colon() {
4003 let (index, _haggle_id) = haggle_index("story::market::barter");
4004 let scope = ImportScope::default();
4005 let uref = divert_uref("barter::haggle", true);
4006 assert_eq!(
4007 lookup_divert(&index, &scope, &[], &uref),
4008 None,
4009 "no import at all must not license the qualified divert"
4010 );
4011
4012 let mut diagnostics = Vec::new();
4013 let mut map = ResolutionMap::new();
4014 resolve_divert(
4015 &index,
4016 &scope,
4017 &[],
4018 FileId(0),
4019 &uref,
4020 &mut map,
4021 &mut diagnostics,
4022 );
4023 assert!(map.is_empty());
4024 assert_eq!(diagnostics.len(), 1);
4025 assert_eq!(diagnostics[0].code, DiagnosticCode::E024);
4026 assert!(
4027 diagnostics[0].message.contains("barter::haggle"),
4028 "the E024 message must spell the qualified path with `::` (the \
4029 native separator actually written), not `.`: {}",
4030 diagnostics[0].message
4031 );
4032 }
4033
4034 /// Bug (b), the dangerous half: `use story::market::barter;` licenses
4035 /// the module-qualified spelling (test above) but must NOT also license
4036 /// the bare `-> haggle` — that was the over-permissive defect issue
4037 /// #2287 reported as its more dangerous half. Reverting
4038 /// `lookup_divert`'s step 2 back to `lookup_by_name` makes this fail:
4039 /// the dual-reading phantom `story::market::barter` entry in
4040 /// `qualified_modules` would classify `haggle` `Candidacy::Imported`,
4041 /// and being the sole candidate, the old fast path returned it
4042 /// unconditionally.
4043 #[test]
4044 fn bare_divert_rejected_after_qualified_module_import_only() {
4045 let (index, _haggle_id) = haggle_index("story::market::barter");
4046 let scope = ImportScope::new(
4047 None,
4048 &[Import {
4049 module: "story::market".to_string(),
4050 module_range: TextRange::default(),
4051 items: vec![ImportItem {
4052 name: "barter".to_string(),
4053 alias: None,
4054 range: TextRange::default(),
4055 }],
4056 bare: true,
4057 range: TextRange::default(),
4058 }],
4059 );
4060 let uref = divert_uref("haggle", false);
4061 assert_eq!(
4062 lookup_divert(&index, &scope, &[], &uref),
4063 None,
4064 "a qualified-module-only import must not license the bare \
4065 `-> haggle` spelling"
4066 );
4067 }
4068
4069 /// Row 3 of the corrected table: `use story::market::barter::haggle;`
4070 /// (a genuine symbol-level bare import — no dual-reading ambiguity,
4071 /// since the whole prefix `story::market::barter` is unambiguously the
4072 /// module and `haggle` is unambiguously the item) must license the bare
4073 /// `-> haggle` spelling.
4074 #[test]
4075 fn bare_divert_resolves_via_symbol_level_import() {
4076 let (index, haggle_id) = haggle_index("story::market::barter");
4077 let scope = ImportScope::new(
4078 None,
4079 &[Import {
4080 module: "story::market::barter".to_string(),
4081 module_range: TextRange::default(),
4082 items: vec![ImportItem {
4083 name: "haggle".to_string(),
4084 alias: None,
4085 range: TextRange::default(),
4086 }],
4087 bare: true,
4088 range: TextRange::default(),
4089 }],
4090 );
4091 let uref = divert_uref("haggle", false);
4092 assert_eq!(
4093 lookup_divert(&index, &scope, &[], &uref),
4094 Some(haggle_id),
4095 "`use story::market::barter::haggle;` must license the bare \
4096 `-> haggle` divert"
4097 );
4098 }
4099
4100 /// With no import at all, `lookup_divert` must still resolve the bare
4101 /// `-> haggle` — end-to-end it is correctly rejected (issue #2287
4102 /// confirms this row already worked), but that rejection is
4103 /// `modules::check`'s separate whole-project `E025`/`E087` gate's job,
4104 /// which only fires on a *resolved* reference (`ResolutionMap` walk,
4105 /// `check_cross_module_refs`'s own doc). This is the pre-existing,
4106 /// deliberate byte-identity guarantee `lookup_by_name_direct`'s own doc
4107 /// describes (`!multiple` sole match wins regardless of candidacy) —
4108 /// `lookup_knot_bare` must reproduce it exactly, not just for bug (b)'s
4109 /// qualified-import-only case. `crates/internal/brink-db/src/db.rs`'s
4110 /// `private_cross_module_reference_is_e087_through_db` and
4111 /// `public_cross_module_reference_without_import_is_e025` are the real
4112 /// end-to-end regression pins for this — this unit test is the
4113 /// resolve-layer half, added after those two briefly regressed to
4114 /// `E024` during this fix's own development (an over-broad first draft
4115 /// of `lookup_knot_bare` rejected *any* non-`InScope`/non-bare-imported
4116 /// candidate, not only a qualified-import-only one).
4117 #[test]
4118 fn bare_divert_still_resolves_with_no_import_deferring_to_the_e025_e087_gate() {
4119 let (index, haggle_id) = haggle_index("story::market::barter");
4120 let uref = divert_uref("haggle", false);
4121 assert_eq!(
4122 lookup_divert(&index, &ImportScope::default(), &[], &uref),
4123 Some(haggle_id),
4124 "a totally unimported cross-module Knot must still resolve here — \
4125 `modules::check`'s E025/E087 gate is what rejects it, with a far \
4126 more precise diagnostic than a bare E024 would give"
4127 );
4128 }
4129
4130 // ── Issue #2298: the remainder #2287/#2296 deliberately left ─────
4131 //
4132 // Item 1 (the live gap): `resolve_function`'s "try knots" step is bug
4133 // (b)'s call-site twin — a bare `haggle()` after only a module-qualified
4134 // import must be rejected exactly like bare `-> haggle` already is.
4135 // Item 2 (latent): `lookup_divert`'s Stitch/Label/Variable+Constant
4136 // steps share the same exclusion now, plus the `Constant` omission
4137 // recorded on issue #2083's thread. Item 3: the rejection message
4138 // names the qualified-import-only candidate it skipped, mirroring
4139 // `modules::check`'s own E025 "import it from" framing.
4140
4141 fn function_uref(path: &str, arg_count: usize) -> UnresolvedRef {
4142 UnresolvedRef {
4143 path: path.to_string(),
4144 range: range(0, path.len() as u32),
4145 kind: RefKind::Function,
4146 scope: Scope::default(),
4147 arg_count: Some(arg_count),
4148 module_qualified: false,
4149 }
4150 }
4151
4152 fn qualified_module_only_scope() -> ImportScope {
4153 ImportScope::new(
4154 None,
4155 &[Import {
4156 module: "story::market".to_string(),
4157 module_range: TextRange::default(),
4158 items: vec![ImportItem {
4159 name: "barter".to_string(),
4160 alias: None,
4161 range: TextRange::default(),
4162 }],
4163 bare: true,
4164 range: TextRange::default(),
4165 }],
4166 )
4167 }
4168
4169 fn symbol_level_import_scope() -> ImportScope {
4170 ImportScope::new(
4171 None,
4172 &[Import {
4173 module: "story::market::barter".to_string(),
4174 module_range: TextRange::default(),
4175 items: vec![ImportItem {
4176 name: "haggle".to_string(),
4177 alias: None,
4178 range: TextRange::default(),
4179 }],
4180 bare: true,
4181 range: TextRange::default(),
4182 }],
4183 )
4184 }
4185
4186 /// Item 1's RED case: before this fix, `resolve_function`'s "try knots"
4187 /// step used the flat `lookup_by_name`/`classify`, under which the
4188 /// dual-reading phantom `story::market::barter` qualified-module entry
4189 /// classified `haggle` `Candidacy::Imported` and — being the sole
4190 /// candidate — the old fast path returned it unconditionally, exactly
4191 /// reproducing #2287 bug (b) for a call instead of a divert.
4192 #[test]
4193 fn bare_call_rejected_after_qualified_module_import_only() {
4194 let (index, _haggle_id) = haggle_index("story::market::barter");
4195 let scope = qualified_module_only_scope();
4196 let uref = function_uref("haggle", 0);
4197 let mut map = ResolutionMap::new();
4198 let mut diagnostics = Vec::new();
4199 resolve_function(
4200 &index,
4201 &scope,
4202 &[],
4203 FileId(0),
4204 &uref,
4205 &mut map,
4206 &mut diagnostics,
4207 );
4208 assert!(
4209 map.is_empty(),
4210 "a qualified-module-only import must not license the bare call \
4211 `haggle()` — issue #2298's live gap, the call-site twin of \
4212 #2287 bug (b): {map:?}"
4213 );
4214 assert_eq!(diagnostics.len(), 1);
4215 assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
4216 }
4217
4218 /// Row 3's call-site twin: a genuine symbol-level bare import
4219 /// (`use story::market::barter::haggle;`) must still license the bare
4220 /// call `haggle()` — the exclusion must not overcorrect into rejecting
4221 /// a legitimately bare-imported knot-as-tunnel-function.
4222 #[test]
4223 fn bare_call_resolves_via_symbol_level_import() {
4224 let (index, haggle_id) = haggle_index("story::market::barter");
4225 let scope = symbol_level_import_scope();
4226 let uref = function_uref("haggle", 0);
4227 let mut map = ResolutionMap::new();
4228 let mut diagnostics = Vec::new();
4229 resolve_function(
4230 &index,
4231 &scope,
4232 &[],
4233 FileId(0),
4234 &uref,
4235 &mut map,
4236 &mut diagnostics,
4237 );
4238 assert!(
4239 diagnostics.is_empty(),
4240 "unexpected diagnostics: {diagnostics:?}"
4241 );
4242 assert_eq!(
4243 map.iter().map(|r| r.target).collect::<Vec<_>>(),
4244 vec![haggle_id],
4245 "`use story::market::barter::haggle;` must license the bare call \
4246 `haggle()`"
4247 );
4248 }
4249
4250 /// Row 4's call-site twin: with no import at all, the call still
4251 /// resolves here — `modules::check`'s separate E025/E087 gate is the
4252 /// one that rejects a genuinely unimported cross-module reference, not
4253 /// this lookup (same deferral `bare_divert_still_resolves_with_no_import_deferring_to_the_e025_e087_gate`
4254 /// pins for diverts).
4255 #[test]
4256 fn bare_call_still_resolves_with_no_import_deferring_to_the_e025_e087_gate() {
4257 let (index, haggle_id) = haggle_index("story::market::barter");
4258 let uref = function_uref("haggle", 0);
4259 let mut map = ResolutionMap::new();
4260 let mut diagnostics = Vec::new();
4261 resolve_function(
4262 &index,
4263 &ImportScope::default(),
4264 &[],
4265 FileId(0),
4266 &uref,
4267 &mut map,
4268 &mut diagnostics,
4269 );
4270 assert_eq!(
4271 map.iter().map(|r| r.target).collect::<Vec<_>>(),
4272 vec![haggle_id],
4273 "a totally unimported cross-module Knot must still resolve at a \
4274 call site, exactly as it does at a divert site"
4275 );
4276 }
4277
4278 /// Item 3: the module-imported-but-bare divert row gets the same
4279 /// "import it from `module`" framing `modules::check`'s own E025
4280 /// already gives the "no import at all" row, instead of a bare,
4281 /// unexplained `E024` naming just `haggle`.
4282 #[test]
4283 fn unresolved_diag_hints_at_qualified_import_only_candidate_for_divert() {
4284 let (index, _haggle_id) = haggle_index("story::market::barter");
4285 let scope = qualified_module_only_scope();
4286 let uref = divert_uref("haggle", false);
4287 let mut map = ResolutionMap::new();
4288 let mut diagnostics = Vec::new();
4289 resolve_divert(
4290 &index,
4291 &scope,
4292 &[],
4293 FileId(0),
4294 &uref,
4295 &mut map,
4296 &mut diagnostics,
4297 );
4298 assert_eq!(diagnostics.len(), 1);
4299 assert_eq!(diagnostics[0].code, DiagnosticCode::E024);
4300 assert!(
4301 diagnostics[0]
4302 .message
4303 .contains("import it from `story::market::barter`"),
4304 "the module-imported-but-bare row must name the qualified-\
4305 import-only candidate it skipped: {}",
4306 diagnostics[0].message
4307 );
4308 }
4309
4310 /// Item 3's call-site twin.
4311 #[test]
4312 fn unresolved_diag_hints_at_qualified_import_only_candidate_for_call() {
4313 let (index, _haggle_id) = haggle_index("story::market::barter");
4314 let scope = qualified_module_only_scope();
4315 let uref = function_uref("haggle", 0);
4316 let mut map = ResolutionMap::new();
4317 let mut diagnostics = Vec::new();
4318 resolve_function(
4319 &index,
4320 &scope,
4321 &[],
4322 FileId(0),
4323 &uref,
4324 &mut map,
4325 &mut diagnostics,
4326 );
4327 assert_eq!(diagnostics.len(), 1);
4328 assert_eq!(diagnostics[0].code, DiagnosticCode::E025);
4329 assert!(
4330 diagnostics[0]
4331 .message
4332 .contains("import it from `story::market::barter`"),
4333 "the module-imported-but-bare call row must name the qualified-\
4334 import-only candidate it skipped too: {}",
4335 diagnostics[0].message
4336 );
4337 }
4338
4339 fn constant_index(name: &str) -> (SymbolIndex, DefinitionId) {
4340 use brink_format::DefinitionTag;
4341 let mut index = SymbolIndex::default();
4342 let id = DefinitionId::new(DefinitionTag::Address, 0x749);
4343 index.symbols.insert(
4344 id,
4345 SymbolInfo {
4346 kind: SymbolKind::Constant,
4347 file: FileId(0),
4348 range: TextRange::default(),
4349 id,
4350 name: name.to_string(),
4351 params: Vec::new(),
4352 detail: None,
4353 scope: None,
4354 param_detail: None,
4355 module: None,
4356 visibility: Visibility::Public,
4357 },
4358 );
4359 index.by_name.entry(name.to_string()).or_default().push(id);
4360 (index, id)
4361 }
4362
4363 /// Item 2's `Constant` omission, credited to issue #2083's thread:
4364 /// `resolve_function`'s own "try variables and constants" step was
4365 /// widened to `[Variable, Constant]` by #2947 for the call-site gap
4366 /// #2083 reported, but `lookup_divert`'s step 6 — the divert-target
4367 /// twin of that same lookup — was left `Variable`-only. A `CONST
4368 /// target = -> knot` could never be diverted to via `-> target` even
4369 /// though the call-site twin already accepts a const-bound value.
4370 ///
4371 /// Probed directly against a synthetic index (not a real native
4372 /// compile): today's native surface has no way to construct a
4373 /// `Constant`-kind divert-target candidate through real source, since
4374 /// a `flow` always classifies `SymbolKind::Knot` — this is the only
4375 /// way to exercise step 6's kind list at all, matching the "probe it;
4376 /// if it turns out unreachable, record WHY" instruction this issue's
4377 /// build carried.
4378 #[test]
4379 fn divert_target_resolves_a_constant_symbol() {
4380 let (index, const_id) = constant_index("target");
4381 let uref = divert_uref("target", false);
4382 assert_eq!(
4383 lookup_divert(&index, &ImportScope::default(), &[], &uref),
4384 Some(const_id),
4385 "a top-level Constant-kind divert target must resolve via step \
4386 6, matching resolve_function's own [Variable, Constant] \
4387 call-site lookup (issue #2083's thread)"
4388 );
4389 }
4390
4391 /// The shadow order the `Constant` widening implies, pinned per the
4392 /// #2298 review round (finding 4): when BOTH a local (param) `target`
4393 /// AND a global `CONST target` (divert-target-holding) exist, the
4394 /// global wins — step 6 runs before the locals step 7, exactly the
4395 /// established order the `Variable` species has always had at this
4396 /// step. Before the widening the param won for the `Constant` species
4397 /// only (step 6 missed `Constant`, step 7 found the local). Same
4398 /// synthetic-index reachability caveat as
4399 /// `divert_target_resolves_a_constant_symbol` above.
4400 #[test]
4401 fn global_constant_divert_target_shadows_a_same_named_local() {
4402 let (index, const_id) = constant_index("target");
4403 let locals = vec![LocalSymbol {
4404 name: "target".to_string(),
4405 range: range(10, 6),
4406 scope: Scope::default(),
4407 kind: SymbolKind::Param,
4408 param_detail: None,
4409 annotation: None,
4410 }];
4411 let uref = divert_uref("target", false);
4412 assert_eq!(
4413 lookup_divert(&index, &ImportScope::default(), &locals, &uref),
4414 Some(const_id),
4415 "a global Constant divert target must shadow a same-named local \
4416 at a divert site, matching the Variable species' established \
4417 step-6-before-step-7 order"
4418 );
4419 }
4420
4421 /// Item 2's plumbing proof: the shared [`lookup_bare_excluding_qualified_only`]
4422 /// honors the exclusion for a non-`Knot` kind too — not just asserted
4423 /// by code inspection. Unreachable via a real native compile today for
4424 /// the same reason as the test above (no `Stitch` candidate can carry
4425 /// a real cross-module `module` yet), so this is a synthetic-index
4426 /// probe of the shared helper directly.
4427 #[test]
4428 fn lookup_bare_excluding_qualified_only_respects_the_exclusion_for_non_knot_kinds() {
4429 use brink_format::DefinitionTag;
4430 let mut index = SymbolIndex::default();
4431 let id = DefinitionId::new(DefinitionTag::Address, 0x750);
4432 index.symbols.insert(
4433 id,
4434 SymbolInfo {
4435 kind: SymbolKind::Stitch,
4436 file: FileId(1),
4437 range: TextRange::default(),
4438 id,
4439 name: "haggle".to_string(),
4440 params: Vec::new(),
4441 detail: None,
4442 scope: None,
4443 param_detail: None,
4444 module: Some("story::market::barter".to_string()),
4445 visibility: Visibility::Public,
4446 },
4447 );
4448 index
4449 .by_name
4450 .entry("haggle".to_string())
4451 .or_default()
4452 .push(id);
4453
4454 let scope = qualified_module_only_scope();
4455 assert_eq!(
4456 lookup_bare_excluding_qualified_only(&index, &scope, "haggle", &[SymbolKind::Stitch]),
4457 None,
4458 "a qualified-module-only import must not license a bare Stitch \
4459 lookup any more than it licenses a bare Knot lookup"
4460 );
4461
4462 // Should-not-fire control: no import at all still defers to the
4463 // resolved-but-unimported gate, exactly like the Knot case.
4464 assert_eq!(
4465 lookup_bare_excluding_qualified_only(
4466 &index,
4467 &ImportScope::default(),
4468 "haggle",
4469 &[SymbolKind::Stitch]
4470 ),
4471 Some(id)
4472 );
4473 }
4474}