Skip to main content

ontogen_ts/
resolve.rs

1//! Per-file `use`-resolution + canonical path normalization.
2//!
3//! When the pool walker encounters a reference to a type in some struct/enum
4//! field, the reference is typically one segment (`DateTime`) or
5//! crate-relative (`crate::models::Workout`). The pool, on the other hand,
6//! is keyed by canonical paths that name the root each item was scanned from
7//! (`["crate", "models", "Workout"]`). Bridging the two requires reading the
8//! source file's `use` declarations and turning them into a lookup table that
9//! one-segment references can consult.
10//!
11//! Rules (matching the OF-015 design pass's "Use-resolution / path
12//! canonicalization" decision):
13//!
14//! - One-segment ref (`DateTime`): consult the file's imports. If the ident
15//!   has a `use` entry, the entry's canonical path wins. If not, fall back
16//!   to "single-segment ident under the current module" — the pool may have
17//!   a matching local key.
18//! - Multi-segment ref (`chrono::DateTime`, `crate::models::Workout`,
19//!   `vaultpolish_core::lint::Severity`): normalized by [`absolutize`], the
20//!   same path used for `use` targets. `crate`/`self`/`super` are relative to
21//!   the referencing module's own root, a bare first segment is either a
22//!   submodule of that module or another scanned root, and anything else is
23//!   external.
24//! - Glob imports (`use chrono::*`) — recorded but raise
25//!   [`EmitError::UnresolvedReference`] when a one-segment ref needs them
26//!   for resolution, since walking the imported crate's source is out of
27//!   phase-1 scope.
28//!
29//! Keys naming their root is what lets several source trees share one pool
30//! (`ClientsConfig::pool_extra_roots`) without a workspace sibling's types
31//! colliding with the consuming crate's. It also makes `crate::` mean the
32//! right thing inside a sibling: relative to *that* crate, not the consumer.
33//!
34//! `#[allow(dead_code)]` is module-wide. As of the closure-edge fix,
35//! [`ModuleImports`] / [`collect_module_imports`] / [`FileImports::resolve_ident`]
36//! ARE wired into the production dep extractor (`order::DepCollector`): a
37//! bare single-segment reference is resolved through its module's `use`
38//! table before any terminal-segment guessing, so a type imported from one
39//! of several same-terminal modules links to the right pool key. The
40//! render-side resolver (`emit::emit_type`'s fall-through) and the
41//! [`canonicalize`] / glob-hint helpers remain staged for a later pass; the
42//! module-wide allow covers those still-unused surfaces.
43
44#![allow(dead_code)]
45
46use std::collections::{BTreeMap, BTreeSet};
47
48use syn::{Item, Path, UseTree};
49
50use crate::types::{EmitError, TypePath};
51
52/// Per-file lookup table built from `use` declarations.
53#[derive(Debug, Clone, Default)]
54pub(crate) struct FileImports {
55    /// `Ident` → canonical path. Populated from `use foo::Bar`, `use foo::Bar as Baz`,
56    /// and `use foo::{Bar, Baz}` declarations.
57    pub(crate) simple: BTreeMap<String, TypePath>,
58    /// Prefixes brought in by glob imports (`use chrono::*`). Stored as
59    /// canonical paths whose terminal segment is `*` semantically (we keep
60    /// only the prefix here). Used to surface a helpful hint when a
61    /// one-segment ref can't be resolved.
62    pub(crate) globs: BTreeSet<TypePath>,
63}
64
65impl FileImports {
66    /// Resolve a one-segment ident through the imports table.
67    /// Returns `Some(canonical_path)` if found.
68    pub(crate) fn resolve_ident(&self, ident: &str) -> Option<TypePath> {
69        self.simple.get(ident).cloned()
70    }
71}
72
73/// Walk a parsed `syn::File`'s top-level `use` declarations and build the
74/// imports table.
75pub(crate) fn parse_imports(file: &syn::File) -> FileImports {
76    let mut out = FileImports::default();
77    imports_from_items(&file.items, &mut out);
78    out
79}
80
81/// Accumulate the `use` declarations directly contained in `items` into
82/// `out`. Does not descend into inline `mod` blocks — those define their own
83/// scope (see [`collect_module_imports`]).
84fn imports_from_items(items: &[Item], out: &mut FileImports) {
85    for item in items {
86        if let Item::Use(item_use) = item {
87            walk_use_tree(&item_use.tree, &mut Vec::new(), out);
88        }
89    }
90}
91
92/// Per-module `use` tables for a scanned source tree, keyed by the module's
93/// canonical path segments (empty = crate root). The keys mirror the type
94/// pool's key prefixes, so a referencing item's module — the pool key with
95/// its terminal dropped — looks up directly.
96#[derive(Debug, Clone, Default)]
97pub struct ModuleImports {
98    by_module: BTreeMap<Vec<String>, FileImports>,
99}
100
101impl ModuleImports {
102    /// The `use` table in scope for `module`, if any were recorded.
103    pub(crate) fn get(&self, module: &[String]) -> Option<&FileImports> {
104        self.by_module.get(module)
105    }
106
107    /// Fold another tree's tables in. On a module-path collision the existing
108    /// entry wins, matching the pool's "first root wins" merge policy in
109    /// `src/clients/mod.rs`.
110    pub fn merge(&mut self, other: ModuleImports) {
111        for (module, imports) in other.by_module {
112            self.by_module.entry(module).or_insert(imports);
113        }
114    }
115}
116
117/// Walk a parsed file's `use` declarations — including those inside inline
118/// `mod foo { ... }` blocks — into `out`, keyed by module path. `prefix` is
119/// the canonical path of the file's own module (empty at the crate root),
120/// matching the pool walker's `module_prefix`.
121pub(crate) fn collect_module_imports(file: &syn::File, prefix: &[String], out: &mut ModuleImports) {
122    collect_items_into(&file.items, prefix, out);
123}
124
125fn collect_items_into(items: &[Item], prefix: &[String], out: &mut ModuleImports) {
126    let entry = out.by_module.entry(prefix.to_vec()).or_default();
127    imports_from_items(items, entry);
128    for item in items {
129        if let Item::Mod(m) = item
130            && let Some((_, inner)) = &m.content
131        {
132            let mut sub = prefix.to_vec();
133            sub.push(m.ident.to_string());
134            collect_items_into(inner, &sub, out);
135        }
136    }
137}
138
139/// Re-export chains can in principle loop (`a` re-exports from `b`, `b` from
140/// `a`). Bound the `use`-chain walk so a pathological cycle terminates.
141const MAX_IMPORT_DEPTH: u8 = 16;
142
143/// Outcome of resolving a type reference against the pool.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Resolution {
146    /// Resolved to exactly one pool key.
147    Resolved(TypePath),
148    /// The reference points outside the pool — a primitive, an external
149    /// crate, or an otherwise unresolvable path. No edge / not a root.
150    NotInPool,
151    /// A bare reference with no disambiguating `use` matched more than one
152    /// pool key by terminal segment. The caller decides what to do: a closure
153    /// edge ignores it (no mislink), a long-tail root errors (the consuming
154    /// crate must qualify or rename).
155    Ambiguous(Vec<TypePath>),
156}
157
158/// Resolve a type reference — `segments` are the reference's path idents with
159/// generic args already stripped (`["BackupManifest"]`,
160/// `["crate","models","Workout"]`) — written in `module` (the referencing
161/// item's canonical module path, i.e. its pool key minus the terminal).
162///
163/// A bare single-segment reference resolves in priority order: the referencing
164/// module's `use` table (authoritative, followed across re-export chains),
165/// then a same-module sibling, then a terminal-segment match that prefers the
166/// referencing module's own root (see [`terminal_resolution`]). A
167/// multi-segment reference is normalized by [`absolutize`] and must land on an
168/// exact pool key. See the [`crate::order`] module docs for the rationale.
169pub fn resolve_reference(
170    segments: &[String],
171    module: &[String],
172    pool: &BTreeMap<TypePath, syn::Item>,
173    imports: &ModuleImports,
174) -> Resolution {
175    match segments {
176        [] => Resolution::NotInPool,
177        [only] => resolve_bare_ident(only, module, pool, imports, 0),
178        // A written multi-segment path goes through the same normalization as
179        // a `use` path: `crate::`/`self::`/`super::` are relative to the
180        // referencing module's own root, a bare first segment is either a
181        // submodule of that module or another scanned root, and anything else
182        // is external. See [`absolutize`].
183        multi => match absolutize(multi, module, pool, imports) {
184            Some(abs) => match TypePath::new(abs) {
185                Ok(tp) if pool.contains_key(&tp) => Resolution::Resolved(tp),
186                // A qualified path we don't have as a definition key — a
187                // re-export path we don't follow for multi-segment refs.
188                _ => Resolution::NotInPool,
189            },
190            None => Resolution::NotInPool,
191        },
192    }
193}
194
195/// Resolve a bare ident written in `module`. `depth` bounds re-export hops.
196fn resolve_bare_ident(
197    ident: &str,
198    module: &[String],
199    pool: &BTreeMap<TypePath, syn::Item>,
200    imports: &ModuleImports,
201    depth: u8,
202) -> Resolution {
203    // 1. The module's `use` table — authoritative (an explicit `use` wins in
204    //    Rust name resolution).
205    if let Some(file_imports) = imports.get(module)
206        && let Some(target) = file_imports.resolve_ident(ident)
207    {
208        return resolve_import_target(target.segments(), module, pool, imports, depth);
209    }
210    // 2. A sibling defined in the same module, referenced without a `use`.
211    let mut same_module = module.to_vec();
212    same_module.push(ident.to_string());
213    if let Ok(path) = TypePath::new(same_module)
214        && pool.contains_key(&path)
215    {
216        return Resolution::Resolved(path);
217    }
218    // 3. A terminal-segment match, preferring the referencing module's own
219    //    root — nothing named this ident explicitly, so it cannot be a
220    //    foreign type (see `terminal_resolution`).
221    terminal_resolution(ident, pool, module.first().map(String::as_str))
222}
223
224/// Resolve the path a `use` points at (`target`, as written in `in_module`)
225/// to a pool key, following one re-export hop if it lands on another module's
226/// re-export rather than a definition.
227fn resolve_import_target(
228    target: &[String],
229    in_module: &[String],
230    pool: &BTreeMap<TypePath, syn::Item>,
231    imports: &ModuleImports,
232    depth: u8,
233) -> Resolution {
234    // The import's terminal — the type name itself — is the fallback key.
235    let Some(leaf) = target.last().cloned() else {
236        return Resolution::NotInPool;
237    };
238    // Normalize the `use` path to an absolute, root-prefixed path. Now that
239    // every pool key names its root, `use pumice_config::ThemePreference`
240    // absolutizes to that sibling's own key and hits exactly. `None` is left
241    // for genuine externals (`use chrono::DateTime`), where a terminal match
242    // is the only thing to try — and finds nothing, which is the right
243    // answer.
244    let Some(abs) = absolutize(target, in_module, pool, imports) else {
245        return terminal_resolution(&leaf, pool, in_module.first().map(String::as_str));
246    };
247    let Ok(abs_path) = TypePath::new(abs.clone()) else {
248        return Resolution::NotInPool;
249    };
250    // Direct hit: the import names the definition's own module path.
251    if pool.contains_key(&abs_path) {
252        return Resolution::Resolved(abs_path);
253    }
254    // Crate-internal but not a definition key — the named module is
255    // re-exporting it (`pub use`). Follow the chain one hop further.
256    if depth < MAX_IMPORT_DEPTH && abs.len() >= 2 {
257        let reexport_module = &abs[..abs.len() - 1];
258        match resolve_bare_ident(&leaf, reexport_module, pool, imports, depth + 1) {
259            // The re-exporting module names `leaf` explicitly: take its answer.
260            resolved @ (Resolution::Resolved(_) | Resolution::Ambiguous(_)) => return resolved,
261            // It doesn't (a glob re-export, say) — fall through to terminal.
262            Resolution::NotInPool => {}
263        }
264    }
265    terminal_resolution(&leaf, pool, in_module.first().map(String::as_str))
266}
267
268/// Normalize a `use` path written in `in_module` to an absolute, root-prefixed
269/// segment vector. Returns `None` only when the path is rooted at a crate that
270/// isn't in the pool at all (a genuine external like `chrono`).
271fn absolutize(
272    target: &[String],
273    in_module: &[String],
274    pool: &BTreeMap<TypePath, syn::Item>,
275    imports: &ModuleImports,
276) -> Option<Vec<String>> {
277    let (first, rest) = target.split_first()?;
278    match first.as_str() {
279        // Relative to whichever root this module belongs to — see
280        // [`rebase_crate_prefix`].
281        "crate" => Some([&in_module[..1.min(in_module.len())], rest].concat()),
282        "self" => Some([in_module, rest].concat()),
283        "super" => {
284            // `use super::X` — parent of the current module, then the rest.
285            let parent = in_module.split_last().map(|(_, p)| p)?;
286            Some([parent, rest].concat())
287        }
288        _ => {
289            // A bare first segment is one of three things, in precedence
290            // order: a submodule of `in_module` (the 2018-edition relative
291            // form, `pub use vault::X` inside `schema`); another scanned root
292            // (a `pool_extra_roots` sibling named by its package, which is
293            // exactly the key prefix); or a genuine external crate.
294            let mut candidate_module = in_module.to_vec();
295            candidate_module.push(first.clone());
296            if is_known_module(&candidate_module, pool, imports) {
297                return Some([in_module, target].concat());
298            }
299            // Rooting keys at their crate is what makes this branch possible:
300            // `use vaultpolish_core::lint::Severity` IS the pool key, so it
301            // resolves exactly instead of falling through to terminal
302            // guessing.
303            if is_known_module(std::slice::from_ref(first), pool, imports) {
304                return Some(target.to_vec());
305            }
306            None
307        }
308    }
309}
310
311/// True when `prefix` names a module that the pool or imports know about —
312/// i.e. some pool key has it as a strict ancestor, or it has a `use` table.
313fn is_known_module(prefix: &[String], pool: &BTreeMap<TypePath, syn::Item>, imports: &ModuleImports) -> bool {
314    if imports.get(prefix).is_some() {
315        return true;
316    }
317    pool.keys().any(|k| {
318        let segs = k.segments();
319        segs.len() > prefix.len() && &segs[..prefix.len()] == prefix
320    })
321}
322
323/// The pool key whose terminal segment equals `ident`: `Resolved` when exactly
324/// one matches, `NotInPool` for none, `Ambiguous` for more than one.
325///
326/// `home_root` is the root of the module doing the referencing. Candidates
327/// from that root are considered first, and only if it has none do candidates
328/// from other roots get a look.
329///
330/// That ordering is Rust's rule, not a tie-break. Terminal matching is a
331/// heuristic Rust itself doesn't have — the language requires a bare ident to
332/// be in scope — and it exists here only to recover references that arrived
333/// through a glob (`use some_crate::*`), since a glob records no
334/// ident-to-path mapping. Any reference brought in by an explicit `use` was
335/// already resolved by step 1 of [`resolve_bare_ident`]. So reaching here
336/// means nothing named the ident explicitly, and Rust says an item declared
337/// or explicitly imported in the referencing crate shadows a glob import.
338/// A same-root collision is still genuinely ambiguous, and still an error.
339fn terminal_resolution(ident: &str, pool: &BTreeMap<TypePath, syn::Item>, home_root: Option<&str>) -> Resolution {
340    let matches: Vec<TypePath> = pool.keys().filter(|p| p.terminal() == ident).cloned().collect();
341    let home: Vec<TypePath> = match home_root {
342        Some(root) => {
343            matches.iter().filter(|p| p.segments().first().map(String::as_str) == Some(root)).cloned().collect()
344        }
345        None => Vec::new(),
346    };
347    let candidates = if home.is_empty() { matches } else { home };
348    match candidates.len() {
349        0 => Resolution::NotInPool,
350        1 => Resolution::Resolved(candidates.into_iter().next().expect("len checked")),
351        _ => Resolution::Ambiguous(candidates),
352    }
353}
354
355/// Recursive walker over `syn::UseTree` — the shape `use a::{b, c::d as e, f::*}`
356/// builds up.
357fn walk_use_tree(tree: &UseTree, prefix: &mut Vec<String>, out: &mut FileImports) {
358    match tree {
359        UseTree::Path(p) => {
360            prefix.push(p.ident.to_string());
361            walk_use_tree(&p.tree, prefix, out);
362            prefix.pop();
363        }
364        UseTree::Name(name) => {
365            // `use foo::Bar;` — `name.ident == "Bar"`; prefix is `["foo"]`.
366            let ident = name.ident.to_string();
367            let mut segments = prefix.clone();
368            segments.push(ident.clone());
369            if let Ok(path) = TypePath::new(segments) {
370                out.simple.insert(ident, path);
371            }
372        }
373        UseTree::Rename(rename) => {
374            // `use foo::Bar as Baz;` — local ident is `Baz`, canonical is
375            // `prefix::Bar`.
376            let canonical_ident = rename.ident.to_string();
377            let local_ident = rename.rename.to_string();
378            let mut segments = prefix.clone();
379            segments.push(canonical_ident);
380            if let Ok(path) = TypePath::new(segments) {
381                out.simple.insert(local_ident, path);
382            }
383        }
384        UseTree::Glob(_) => {
385            // `use foo::bar::*;` — record the prefix; one-segment refs hit
386            // `UnresolvedReference` with a hint that this glob may be the
387            // missing source.
388            if !prefix.is_empty()
389                && let Ok(path) = TypePath::new(prefix.clone())
390            {
391                out.globs.insert(path);
392            }
393        }
394        UseTree::Group(group) => {
395            for inner in &group.items {
396                walk_use_tree(inner, prefix, out);
397            }
398        }
399    }
400}
401
402/// Strip generic args from a [`syn::Path`] and return the segment idents as
403/// a vector. `Path<A, B>` → `["Path"]`; `foo::bar::Baz<u32>` →
404/// `["foo", "bar", "Baz"]`.
405fn path_segments(path: &Path) -> Vec<String> {
406    path.segments.iter().map(|seg| seg.ident.to_string()).collect()
407}
408
409/// Canonicalize a referenced `syn::Path` against the file's imports.
410///
411/// `referenced_by` is the type whose field carries this reference — included
412/// in errors for context.
413///
414/// Returns the canonical [`TypePath`] suitable for lookup in either the
415/// pool (project-relative) or the external-types table (full canonical
416/// path).
417pub(crate) fn canonicalize(
418    path: &Path,
419    imports: &FileImports,
420    referenced_by: &TypePath,
421) -> Result<TypePath, EmitError> {
422    let mut segments = path_segments(path);
423
424    if segments.is_empty() {
425        return Err(EmitError::UnresolvedReference {
426            name: "<empty path>".to_string(),
427            referenced_by: referenced_by.clone(),
428        });
429    }
430
431    // Multi-segment path: take as-qualified, strip `crate::` for pool lookup.
432    if segments.len() > 1 {
433        if segments.first().map(String::as_str) == Some("crate") {
434            segments.remove(0);
435        }
436        return TypePath::new(segments).map_err(|_| EmitError::UnresolvedReference {
437            name: "<empty after crate:: stripped>".to_string(),
438            referenced_by: referenced_by.clone(),
439        });
440    }
441
442    // One-segment ident: consult imports.
443    let ident = &segments[0];
444    if let Some(path) = imports.resolve_ident(ident) {
445        return Ok(path);
446    }
447
448    // Not in imports. If any glob imports are present, surface a hint —
449    // the ident may live in one of those globs and we can't tell without
450    // walking the imported crate's source.
451    if !imports.globs.is_empty() {
452        let globs_rendered: Vec<String> =
453            imports.globs.iter().map(|p| format!("use {}::*;", p.segments().join("::"))).collect();
454        return Err(EmitError::UnresolvedReference {
455            name: format!(
456                "`{ident}` (may come from {}; qualify the reference (e.g., chrono::{ident}) or replace the glob with \
457                 an explicit `use`)",
458                globs_rendered.join(", ")
459            ),
460            referenced_by: referenced_by.clone(),
461        });
462    }
463
464    // Bare one-segment ident with no matching `use`: treat as a local type
465    // at the crate root. The pool walker may have it; the lookup happens
466    // at the call site. If neither pool nor external-types match, the
467    // emitter surfaces `UnresolvedReference` later.
468    TypePath::new(vec![ident.clone()])
469        .map_err(|_| EmitError::UnresolvedReference { name: ident.clone(), referenced_by: referenced_by.clone() })
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn parse_file(src: &str) -> syn::File {
477        syn::parse_str(src).expect("parse file")
478    }
479
480    /// Prepend the local-crate root, matching what the pool walker produces.
481    fn rooted(segments: &[&str]) -> Vec<String> {
482        std::iter::once("crate".to_string()).chain(segments.iter().map(|s| (*s).to_string())).collect()
483    }
484
485    /// An expected pool key in the local crate.
486    fn tp(segments: &[&str]) -> TypePath {
487        TypePath::new(rooted(segments)).expect("non-empty")
488    }
489
490    /// An expected pool key with an explicit root — for extra-root siblings.
491    fn tp_in(segments: &[&str]) -> TypePath {
492        TypePath::new(segments.iter().map(|s| (*s).to_string()).collect()).expect("non-empty")
493    }
494
495    fn parse_path(src: &str) -> Path {
496        syn::parse_str(src).expect("parse path")
497    }
498
499    /// Synthetic pool rooted at the local crate, as `scan_src_dir` would key it.
500    fn pool_from(entries: &[(&[&str], &str)]) -> BTreeMap<TypePath, syn::Item> {
501        entries
502            .iter()
503            .map(|(segs, src)| {
504                (TypePath::new(rooted(segs)).expect("non-empty"), syn::parse_str::<syn::Item>(src).expect("parse item"))
505            })
506            .collect()
507    }
508
509    /// Merge additional entries whose roots are given verbatim — used to model
510    /// a `pool_extra_roots` sibling alongside the local crate.
511    fn with_root(
512        mut pool: BTreeMap<TypePath, syn::Item>,
513        entries: &[(&[&str], &str)],
514    ) -> BTreeMap<TypePath, syn::Item> {
515        for (segs, src) in entries {
516            let key = TypePath::new(segs.iter().map(|s| (*s).to_string()).collect()).expect("non-empty");
517            pool.insert(key, syn::parse_str::<syn::Item>(src).expect("parse item"));
518        }
519        pool
520    }
521
522    fn imports_from(entries: &[(&[&str], &str)]) -> ModuleImports {
523        let mut imports = ModuleImports::default();
524        for (module, src) in entries {
525            let file = parse_file(src);
526            collect_module_imports(&file, &rooted(module), &mut imports);
527        }
528        imports
529    }
530
531    /// Imports for modules whose roots are given verbatim.
532    fn imports_in(entries: &[(&[&str], &str)]) -> ModuleImports {
533        let mut imports = ModuleImports::default();
534        for (module, src) in entries {
535            let file = parse_file(src);
536            let prefix: Vec<String> = module.iter().map(|s| (*s).to_string()).collect();
537            collect_module_imports(&file, &prefix, &mut imports);
538        }
539        imports
540    }
541
542    /// A written source path — NOT rooted; this is what appears in the code.
543    fn seg(parts: &[&str]) -> Vec<String> {
544        parts.iter().map(|s| (*s).to_string()).collect()
545    }
546
547    /// A referencing module's canonical path in the local crate.
548    fn md(parts: &[&str]) -> Vec<String> {
549        rooted(parts)
550    }
551
552    // ── resolve_reference ─────────────────────────────────────────────────
553
554    #[test]
555    fn reference_resolves_relative_reexport_chain() {
556        // The Pumice `VaultConfig` shape, which broke the first cut:
557        //   api::v1::vault   `use crate::schema::VaultConfig;`        (the API site)
558        //   schema (mod.rs)  `pub use vault::VaultConfig;`            (RELATIVE re-export)
559        //   schema::vault    `pub struct VaultConfig { … }`           (the definition)
560        //   vault            `pub struct VaultConfig { … }`           (an unrelated same-name type)
561        // The bare `VaultConfig` referenced in api::v1::vault must resolve to
562        // schema::vault::VaultConfig — through the `use` + relative re-export —
563        // NOT to the sibling `vault::VaultConfig`.
564        let pool = pool_from(&[
565            (&["schema", "vault", "VaultConfig"], "pub struct VaultConfig { pub template: String }"),
566            (&["vault", "VaultConfig"], "pub struct VaultConfig { pub enabled: bool }"),
567        ]);
568        let imports = imports_from(&[
569            (&["api", "v1", "vault"], "use crate::schema::VaultConfig;"),
570            (&["schema"], "pub use vault::VaultConfig;"),
571        ]);
572        let r = resolve_reference(&seg(&["VaultConfig"]), &md(&["api", "v1", "vault"]), &pool, &imports);
573        assert_eq!(r, Resolution::Resolved(tp(&["schema", "vault", "VaultConfig"])), "got {r:?}");
574    }
575
576    #[test]
577    fn reference_without_disambiguating_use_is_ambiguous() {
578        // Same colliding pool, but the referencing module has no `use` for
579        // `VaultConfig` — the resolver must report Ambiguous, never guess.
580        let pool = pool_from(&[
581            (&["schema", "vault", "VaultConfig"], "pub struct VaultConfig { pub template: String }"),
582            (&["vault", "VaultConfig"], "pub struct VaultConfig { pub enabled: bool }"),
583        ]);
584        let imports = ModuleImports::default();
585        let r = resolve_reference(&seg(&["VaultConfig"]), &md(&["api", "v1", "vault"]), &pool, &imports);
586        match r {
587            Resolution::Ambiguous(cands) => assert_eq!(cands.len(), 2, "got {cands:?}"),
588            other => panic!("expected Ambiguous, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn reference_through_crate_absolute_reexport_chain() {
594        // Same as the relative case but the facade re-exports with an absolute
595        // `pub use crate::core::Foo;`.
596        let pool = pool_from(&[(&["core", "Foo"], "pub struct Foo { pub x: u32 }")]);
597        let imports = imports_from(&[(&["c"], "use crate::facade::Foo;"), (&["facade"], "pub use crate::core::Foo;")]);
598        let r = resolve_reference(&seg(&["Foo"]), &md(&["c"]), &pool, &imports);
599        assert_eq!(r, Resolution::Resolved(tp(&["core", "Foo"])), "got {r:?}");
600    }
601
602    #[test]
603    fn cross_crate_use_hits_the_sibling_key_exactly() {
604        // `use pumice_config::ThemePreference;` — a `pool_extra_roots`
605        // sibling. Its key names its own crate, so the written path IS the
606        // key and resolution is exact rather than a terminal guess.
607        let pool = with_root(
608            BTreeMap::new(),
609            &[(&["pumice_config", "ui", "ThemePreference"], "pub enum ThemePreference { Light, Dark }")],
610        );
611        let imports = imports_from(&[(&["schema", "settings"], "use pumice_config::ThemePreference;")]);
612        let r = resolve_reference(&seg(&["ThemePreference"]), &md(&["schema", "settings"]), &pool, &imports);
613        assert_eq!(r, Resolution::Resolved(tp_in(&["pumice_config", "ui", "ThemePreference"])), "got {r:?}");
614    }
615
616    #[test]
617    fn cross_crate_qualified_path_resolves_without_a_use() {
618        // The form issue #84 wanted to work: name the sibling type outright.
619        // Before rooting, `absolutize` returned None for any non-local first
620        // segment and this fell through to terminal guessing.
621        let pool =
622            with_root(BTreeMap::new(), &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Error }")]);
623        let r = resolve_reference(
624            &seg(&["vaultpolish_core", "lint", "Severity"]),
625            &md(&["api", "v1", "scan"]),
626            &pool,
627            &ModuleImports::default(),
628        );
629        assert_eq!(r, Resolution::Resolved(tp_in(&["vaultpolish_core", "lint", "Severity"])), "got {r:?}");
630    }
631
632    #[test]
633    fn bare_ident_colliding_across_roots_takes_the_local_one() {
634        // The reported #84 failure. A local mirror and a sibling type share a
635        // terminal, and the reference arrives with nothing naming it — a glob
636        // import, typically. Rust says a locally declared or explicitly
637        // imported item shadows a glob, and a bare ident can never reach a
638        // foreign crate's type unaided, so the local key is the only answer
639        // that could be right. This used to be a build-failing Ambiguous.
640        let pool = with_root(
641            pool_from(&[(&["schema", "scan", "Severity"], "pub enum Severity { Error, Warning, Info }")]),
642            &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Error }")],
643        );
644        let r = resolve_reference(&seg(&["Severity"]), &md(&["api", "v1", "scan"]), &pool, &ModuleImports::default());
645        assert_eq!(r, Resolution::Resolved(tp(&["schema", "scan", "Severity"])), "got {r:?}");
646    }
647
648    #[test]
649    fn a_sibling_referencing_itself_stays_in_its_own_crate() {
650        // `crate::` is relative to whichever root the referencing module is
651        // in. With flat keys, a sibling's own `use crate::lint::Severity`
652        // resolved against the merged namespace where the local crate had
653        // already won the key — silently yielding the wrong type's shape.
654        let pool = with_root(
655            pool_from(&[(&["lint", "Severity"], "pub enum Severity { Local }")]),
656            &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Sibling }")],
657        );
658        let r = resolve_reference(
659            &seg(&["crate", "lint", "Severity"]),
660            &["vaultpolish_core".to_string(), "scan".to_string()],
661            &pool,
662            &ModuleImports::default(),
663        );
664        assert_eq!(r, Resolution::Resolved(tp_in(&["vaultpolish_core", "lint", "Severity"])), "got {r:?}");
665
666        // And the same text written in the local crate still means the local one.
667        let r =
668            resolve_reference(&seg(&["crate", "lint", "Severity"]), &md(&["scan"]), &pool, &ModuleImports::default());
669        assert_eq!(r, Resolution::Resolved(tp(&["lint", "Severity"])), "got {r:?}");
670    }
671
672    #[test]
673    fn same_root_collision_is_still_ambiguous() {
674        // Local preference only breaks ties ACROSS roots. Two same-named
675        // types in one crate with nothing to disambiguate is a genuine
676        // ambiguity that rustc would reject too, so it stays an error.
677        let pool = with_root(
678            pool_from(&[
679                (&["a", "Severity"], "pub enum Severity { X }"),
680                (&["b", "Severity"], "pub enum Severity { Y }"),
681            ]),
682            &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Z }")],
683        );
684        let r = resolve_reference(&seg(&["Severity"]), &md(&["api"]), &pool, &ModuleImports::default());
685        match r {
686            Resolution::Ambiguous(cands) => {
687                assert_eq!(cands.len(), 2, "only the two local candidates compete: {cands:?}");
688                assert!(cands.iter().all(|p| p.segments()[0] == "crate"), "got {cands:?}");
689            }
690            other => panic!("expected Ambiguous, got {other:?}"),
691        }
692    }
693
694    #[test]
695    fn external_use_with_no_pool_match_is_not_in_pool() {
696        // `use chrono::DateTime;` with no pool type sharing the terminal —
697        // genuinely external, so no resolution.
698        let pool = pool_from(&[(&["models", "Workout"], "pub struct Workout { pub id: u64 }")]);
699        let imports = imports_from(&[(&["c"], "use chrono::DateTime;")]);
700        let r = resolve_reference(&seg(&["DateTime"]), &md(&["c"]), &pool, &imports);
701        assert_eq!(r, Resolution::NotInPool, "got {r:?}");
702    }
703
704    #[test]
705    fn reference_unique_terminal_without_imports_resolves() {
706        // No imports, bare ident, exactly one pool key with that terminal.
707        let pool = pool_from(&[(&["schema", "backup", "BackupManifest"], "pub struct BackupManifest { pub v: u32 }")]);
708        let r = resolve_reference(&seg(&["BackupManifest"]), &md(&["api"]), &pool, &ModuleImports::default());
709        assert_eq!(r, Resolution::Resolved(tp(&["schema", "backup", "BackupManifest"])), "got {r:?}");
710    }
711
712    #[test]
713    fn reference_qualified_crate_path_matches_exact_key() {
714        let pool = pool_from(&[(&["models", "Workout"], "pub struct Workout { pub id: u64 }")]);
715        let r =
716            resolve_reference(&seg(&["crate", "models", "Workout"]), &md(&["api"]), &pool, &ModuleImports::default());
717        assert_eq!(r, Resolution::Resolved(tp(&["models", "Workout"])), "got {r:?}");
718    }
719
720    // ── parse_imports ─────────────────────────────────────────────────────
721
722    #[test]
723    fn parse_simple_use() {
724        let f = parse_file("use chrono::DateTime;");
725        let imports = parse_imports(&f);
726        assert_eq!(imports.simple.get("DateTime"), Some(&tp_in(&["chrono", "DateTime"])));
727    }
728
729    #[test]
730    fn parse_use_with_rename() {
731        let f = parse_file("use chrono::DateTime as Moment;");
732        let imports = parse_imports(&f);
733        assert_eq!(imports.simple.get("Moment"), Some(&tp_in(&["chrono", "DateTime"])));
734        // The original ident isn't re-mapped.
735        assert!(!imports.simple.contains_key("DateTime"));
736    }
737
738    #[test]
739    fn parse_use_with_group() {
740        let f = parse_file("use chrono::{DateTime, NaiveDate, NaiveTime};");
741        let imports = parse_imports(&f);
742        assert_eq!(imports.simple.get("DateTime"), Some(&tp_in(&["chrono", "DateTime"])));
743        assert_eq!(imports.simple.get("NaiveDate"), Some(&tp_in(&["chrono", "NaiveDate"])));
744        assert_eq!(imports.simple.get("NaiveTime"), Some(&tp_in(&["chrono", "NaiveTime"])));
745    }
746
747    #[test]
748    fn parse_nested_group() {
749        let f = parse_file("use foo::{bar::Baz, qux::{Quux, Quuux as Q}};");
750        let imports = parse_imports(&f);
751        assert_eq!(imports.simple.get("Baz"), Some(&tp_in(&["foo", "bar", "Baz"])));
752        assert_eq!(imports.simple.get("Quux"), Some(&tp_in(&["foo", "qux", "Quux"])));
753        assert_eq!(imports.simple.get("Q"), Some(&tp_in(&["foo", "qux", "Quuux"])));
754    }
755
756    #[test]
757    fn parse_glob_import() {
758        let f = parse_file("use chrono::*;");
759        let imports = parse_imports(&f);
760        assert!(imports.globs.contains(&tp_in(&["chrono"])));
761        assert!(imports.simple.is_empty());
762    }
763
764    #[test]
765    fn parse_multiple_glob_imports() {
766        let f = parse_file("use chrono::*; use uuid::*;");
767        let imports = parse_imports(&f);
768        assert!(imports.globs.contains(&tp_in(&["chrono"])));
769        assert!(imports.globs.contains(&tp_in(&["uuid"])));
770    }
771
772    // ── canonicalize ──────────────────────────────────────────────────────
773
774    #[test]
775    fn canonicalize_single_segment_via_imports() {
776        let f = parse_file("use chrono::DateTime;");
777        let imports = parse_imports(&f);
778        let path = parse_path("DateTime");
779        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
780        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
781    }
782
783    #[test]
784    fn canonicalize_single_segment_via_rename() {
785        let f = parse_file("use chrono::DateTime as Moment;");
786        let imports = parse_imports(&f);
787        let path = parse_path("Moment");
788        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
789        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
790    }
791
792    #[test]
793    fn canonicalize_unresolved_single_segment_falls_through() {
794        // No imports, no globs — treated as a bare local ident.
795        let f = parse_file("");
796        let imports = parse_imports(&f);
797        let path = parse_path("MyWorkout");
798        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
799        assert_eq!(resolved, tp_in(&["MyWorkout"]));
800    }
801
802    #[test]
803    fn canonicalize_unresolved_with_glob_emits_hint() {
804        let f = parse_file("use chrono::*;");
805        let imports = parse_imports(&f);
806        let path = parse_path("DateTime");
807        let err = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap_err();
808        match err {
809            EmitError::UnresolvedReference { name, .. } => {
810                assert!(name.contains("DateTime"), "name was: {name}");
811                assert!(name.contains("chrono"), "name was: {name}");
812                assert!(name.contains("glob") || name.contains("qualify"), "hint missing: {name}");
813            }
814            other => panic!("expected UnresolvedReference, got {other:?}"),
815        }
816    }
817
818    #[test]
819    fn canonicalize_multi_segment_taken_as_qualified() {
820        let f = parse_file("");
821        let imports = parse_imports(&f);
822        let path = parse_path("chrono::DateTime");
823        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
824        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
825    }
826
827    #[test]
828    fn canonicalize_strips_crate_prefix() {
829        let f = parse_file("");
830        let imports = parse_imports(&f);
831        let path = parse_path("crate::models::Workout");
832        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
833        // `crate::` stripped — pool keys are crate-relative.
834        assert_eq!(resolved, tp_in(&["models", "Workout"]));
835    }
836
837    #[test]
838    fn canonicalize_strips_generic_args() {
839        let f = parse_file("");
840        let imports = parse_imports(&f);
841        let path = parse_path("chrono::DateTime<Utc>");
842        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
843        // Generic args don't affect the canonical name.
844        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
845    }
846}