ontogen-ts 0.1.4

Rust AST → TypeScript emitter for ontogen's long-tail type bindings
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
//! Per-file `use`-resolution + canonical path normalization.
//!
//! When the pool walker encounters a reference to a type in some struct/enum
//! field, the reference is typically one segment (`DateTime`) or
//! crate-relative (`crate::models::Workout`). The pool, on the other hand,
//! is keyed by canonical paths that name the root each item was scanned from
//! (`["crate", "models", "Workout"]`). Bridging the two requires reading the
//! source file's `use` declarations and turning them into a lookup table that
//! one-segment references can consult.
//!
//! Rules (matching the OF-015 design pass's "Use-resolution / path
//! canonicalization" decision):
//!
//! - One-segment ref (`DateTime`): consult the file's imports. If the ident
//!   has a `use` entry, the entry's canonical path wins. If not, fall back
//!   to "single-segment ident under the current module" — the pool may have
//!   a matching local key.
//! - Multi-segment ref (`chrono::DateTime`, `crate::models::Workout`,
//!   `vaultpolish_core::lint::Severity`): normalized by [`absolutize`], the
//!   same path used for `use` targets. `crate`/`self`/`super` are relative to
//!   the referencing module's own root, a bare first segment is either a
//!   submodule of that module or another scanned root, and anything else is
//!   external.
//! - Glob imports (`use chrono::*`) — recorded but raise
//!   [`EmitError::UnresolvedReference`] when a one-segment ref needs them
//!   for resolution, since walking the imported crate's source is out of
//!   phase-1 scope.
//!
//! Keys naming their root is what lets several source trees share one pool
//! (`ClientsConfig::pool_extra_roots`) without a workspace sibling's types
//! colliding with the consuming crate's. It also makes `crate::` mean the
//! right thing inside a sibling: relative to *that* crate, not the consumer.
//!
//! `#[allow(dead_code)]` is module-wide. As of the closure-edge fix,
//! [`ModuleImports`] / [`collect_module_imports`] / [`FileImports::resolve_ident`]
//! ARE wired into the production dep extractor (`order::DepCollector`): a
//! bare single-segment reference is resolved through its module's `use`
//! table before any terminal-segment guessing, so a type imported from one
//! of several same-terminal modules links to the right pool key. The
//! render-side resolver (`emit::emit_type`'s fall-through) and the
//! [`canonicalize`] / glob-hint helpers remain staged for a later pass; the
//! module-wide allow covers those still-unused surfaces.

#![allow(dead_code)]

use std::collections::{BTreeMap, BTreeSet};

use syn::{Item, Path, UseTree};

use crate::types::{EmitError, TypePath};

/// Per-file lookup table built from `use` declarations.
#[derive(Debug, Clone, Default)]
pub(crate) struct FileImports {
    /// `Ident` → canonical path. Populated from `use foo::Bar`, `use foo::Bar as Baz`,
    /// and `use foo::{Bar, Baz}` declarations.
    pub(crate) simple: BTreeMap<String, TypePath>,
    /// Prefixes brought in by glob imports (`use chrono::*`). Stored as
    /// canonical paths whose terminal segment is `*` semantically (we keep
    /// only the prefix here). Used to surface a helpful hint when a
    /// one-segment ref can't be resolved.
    pub(crate) globs: BTreeSet<TypePath>,
}

impl FileImports {
    /// Resolve a one-segment ident through the imports table.
    /// Returns `Some(canonical_path)` if found.
    pub(crate) fn resolve_ident(&self, ident: &str) -> Option<TypePath> {
        self.simple.get(ident).cloned()
    }
}

/// Walk a parsed `syn::File`'s top-level `use` declarations and build the
/// imports table.
pub(crate) fn parse_imports(file: &syn::File) -> FileImports {
    let mut out = FileImports::default();
    imports_from_items(&file.items, &mut out);
    out
}

/// Accumulate the `use` declarations directly contained in `items` into
/// `out`. Does not descend into inline `mod` blocks — those define their own
/// scope (see [`collect_module_imports`]).
fn imports_from_items(items: &[Item], out: &mut FileImports) {
    for item in items {
        if let Item::Use(item_use) = item {
            walk_use_tree(&item_use.tree, &mut Vec::new(), out);
        }
    }
}

/// Per-module `use` tables for a scanned source tree, keyed by the module's
/// canonical path segments (empty = crate root). The keys mirror the type
/// pool's key prefixes, so a referencing item's module — the pool key with
/// its terminal dropped — looks up directly.
#[derive(Debug, Clone, Default)]
pub struct ModuleImports {
    by_module: BTreeMap<Vec<String>, FileImports>,
}

impl ModuleImports {
    /// The `use` table in scope for `module`, if any were recorded.
    pub(crate) fn get(&self, module: &[String]) -> Option<&FileImports> {
        self.by_module.get(module)
    }

    /// Fold another tree's tables in. On a module-path collision the existing
    /// entry wins, matching the pool's "first root wins" merge policy in
    /// `src/clients/mod.rs`.
    pub fn merge(&mut self, other: ModuleImports) {
        for (module, imports) in other.by_module {
            self.by_module.entry(module).or_insert(imports);
        }
    }
}

/// Walk a parsed file's `use` declarations — including those inside inline
/// `mod foo { ... }` blocks — into `out`, keyed by module path. `prefix` is
/// the canonical path of the file's own module (empty at the crate root),
/// matching the pool walker's `module_prefix`.
pub(crate) fn collect_module_imports(file: &syn::File, prefix: &[String], out: &mut ModuleImports) {
    collect_items_into(&file.items, prefix, out);
}

fn collect_items_into(items: &[Item], prefix: &[String], out: &mut ModuleImports) {
    let entry = out.by_module.entry(prefix.to_vec()).or_default();
    imports_from_items(items, entry);
    for item in items {
        if let Item::Mod(m) = item
            && let Some((_, inner)) = &m.content
        {
            let mut sub = prefix.to_vec();
            sub.push(m.ident.to_string());
            collect_items_into(inner, &sub, out);
        }
    }
}

/// Re-export chains can in principle loop (`a` re-exports from `b`, `b` from
/// `a`). Bound the `use`-chain walk so a pathological cycle terminates.
const MAX_IMPORT_DEPTH: u8 = 16;

/// Outcome of resolving a type reference against the pool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
    /// Resolved to exactly one pool key.
    Resolved(TypePath),
    /// The reference points outside the pool — a primitive, an external
    /// crate, or an otherwise unresolvable path. No edge / not a root.
    NotInPool,
    /// A bare reference with no disambiguating `use` matched more than one
    /// pool key by terminal segment. The caller decides what to do: a closure
    /// edge ignores it (no mislink), a long-tail root errors (the consuming
    /// crate must qualify or rename).
    Ambiguous(Vec<TypePath>),
}

/// Resolve a type reference — `segments` are the reference's path idents with
/// generic args already stripped (`["BackupManifest"]`,
/// `["crate","models","Workout"]`) — written in `module` (the referencing
/// item's canonical module path, i.e. its pool key minus the terminal).
///
/// A bare single-segment reference resolves in priority order: the referencing
/// module's `use` table (authoritative, followed across re-export chains),
/// then a same-module sibling, then a terminal-segment match that prefers the
/// referencing module's own root (see [`terminal_resolution`]). A
/// multi-segment reference is normalized by [`absolutize`] and must land on an
/// exact pool key. See the [`crate::order`] module docs for the rationale.
pub fn resolve_reference(
    segments: &[String],
    module: &[String],
    pool: &BTreeMap<TypePath, syn::Item>,
    imports: &ModuleImports,
) -> Resolution {
    match segments {
        [] => Resolution::NotInPool,
        [only] => resolve_bare_ident(only, module, pool, imports, 0),
        // A written multi-segment path goes through the same normalization as
        // a `use` path: `crate::`/`self::`/`super::` are relative to the
        // referencing module's own root, a bare first segment is either a
        // submodule of that module or another scanned root, and anything else
        // is external. See [`absolutize`].
        multi => match absolutize(multi, module, pool, imports) {
            Some(abs) => match TypePath::new(abs) {
                Ok(tp) if pool.contains_key(&tp) => Resolution::Resolved(tp),
                // A qualified path we don't have as a definition key — a
                // re-export path we don't follow for multi-segment refs.
                _ => Resolution::NotInPool,
            },
            None => Resolution::NotInPool,
        },
    }
}

/// Resolve a bare ident written in `module`. `depth` bounds re-export hops.
fn resolve_bare_ident(
    ident: &str,
    module: &[String],
    pool: &BTreeMap<TypePath, syn::Item>,
    imports: &ModuleImports,
    depth: u8,
) -> Resolution {
    // 1. The module's `use` table — authoritative (an explicit `use` wins in
    //    Rust name resolution).
    if let Some(file_imports) = imports.get(module)
        && let Some(target) = file_imports.resolve_ident(ident)
    {
        return resolve_import_target(target.segments(), module, pool, imports, depth);
    }
    // 2. A sibling defined in the same module, referenced without a `use`.
    let mut same_module = module.to_vec();
    same_module.push(ident.to_string());
    if let Ok(path) = TypePath::new(same_module)
        && pool.contains_key(&path)
    {
        return Resolution::Resolved(path);
    }
    // 3. A terminal-segment match, preferring the referencing module's own
    //    root — nothing named this ident explicitly, so it cannot be a
    //    foreign type (see `terminal_resolution`).
    terminal_resolution(ident, pool, module.first().map(String::as_str))
}

/// Resolve the path a `use` points at (`target`, as written in `in_module`)
/// to a pool key, following one re-export hop if it lands on another module's
/// re-export rather than a definition.
fn resolve_import_target(
    target: &[String],
    in_module: &[String],
    pool: &BTreeMap<TypePath, syn::Item>,
    imports: &ModuleImports,
    depth: u8,
) -> Resolution {
    // The import's terminal — the type name itself — is the fallback key.
    let Some(leaf) = target.last().cloned() else {
        return Resolution::NotInPool;
    };
    // Normalize the `use` path to an absolute, root-prefixed path. Now that
    // every pool key names its root, `use pumice_config::ThemePreference`
    // absolutizes to that sibling's own key and hits exactly. `None` is left
    // for genuine externals (`use chrono::DateTime`), where a terminal match
    // is the only thing to try — and finds nothing, which is the right
    // answer.
    let Some(abs) = absolutize(target, in_module, pool, imports) else {
        return terminal_resolution(&leaf, pool, in_module.first().map(String::as_str));
    };
    let Ok(abs_path) = TypePath::new(abs.clone()) else {
        return Resolution::NotInPool;
    };
    // Direct hit: the import names the definition's own module path.
    if pool.contains_key(&abs_path) {
        return Resolution::Resolved(abs_path);
    }
    // Crate-internal but not a definition key — the named module is
    // re-exporting it (`pub use`). Follow the chain one hop further.
    if depth < MAX_IMPORT_DEPTH && abs.len() >= 2 {
        let reexport_module = &abs[..abs.len() - 1];
        match resolve_bare_ident(&leaf, reexport_module, pool, imports, depth + 1) {
            // The re-exporting module names `leaf` explicitly: take its answer.
            resolved @ (Resolution::Resolved(_) | Resolution::Ambiguous(_)) => return resolved,
            // It doesn't (a glob re-export, say) — fall through to terminal.
            Resolution::NotInPool => {}
        }
    }
    terminal_resolution(&leaf, pool, in_module.first().map(String::as_str))
}

/// Normalize a `use` path written in `in_module` to an absolute, root-prefixed
/// segment vector. Returns `None` only when the path is rooted at a crate that
/// isn't in the pool at all (a genuine external like `chrono`).
fn absolutize(
    target: &[String],
    in_module: &[String],
    pool: &BTreeMap<TypePath, syn::Item>,
    imports: &ModuleImports,
) -> Option<Vec<String>> {
    let (first, rest) = target.split_first()?;
    match first.as_str() {
        // Relative to whichever root this module belongs to — see
        // [`rebase_crate_prefix`].
        "crate" => Some([&in_module[..1.min(in_module.len())], rest].concat()),
        "self" => Some([in_module, rest].concat()),
        "super" => {
            // `use super::X` — parent of the current module, then the rest.
            let parent = in_module.split_last().map(|(_, p)| p)?;
            Some([parent, rest].concat())
        }
        _ => {
            // A bare first segment is one of three things, in precedence
            // order: a submodule of `in_module` (the 2018-edition relative
            // form, `pub use vault::X` inside `schema`); another scanned root
            // (a `pool_extra_roots` sibling named by its package, which is
            // exactly the key prefix); or a genuine external crate.
            let mut candidate_module = in_module.to_vec();
            candidate_module.push(first.clone());
            if is_known_module(&candidate_module, pool, imports) {
                return Some([in_module, target].concat());
            }
            // Rooting keys at their crate is what makes this branch possible:
            // `use vaultpolish_core::lint::Severity` IS the pool key, so it
            // resolves exactly instead of falling through to terminal
            // guessing.
            if is_known_module(std::slice::from_ref(first), pool, imports) {
                return Some(target.to_vec());
            }
            None
        }
    }
}

/// True when `prefix` names a module that the pool or imports know about —
/// i.e. some pool key has it as a strict ancestor, or it has a `use` table.
fn is_known_module(prefix: &[String], pool: &BTreeMap<TypePath, syn::Item>, imports: &ModuleImports) -> bool {
    if imports.get(prefix).is_some() {
        return true;
    }
    pool.keys().any(|k| {
        let segs = k.segments();
        segs.len() > prefix.len() && &segs[..prefix.len()] == prefix
    })
}

/// The pool key whose terminal segment equals `ident`: `Resolved` when exactly
/// one matches, `NotInPool` for none, `Ambiguous` for more than one.
///
/// `home_root` is the root of the module doing the referencing. Candidates
/// from that root are considered first, and only if it has none do candidates
/// from other roots get a look.
///
/// That ordering is Rust's rule, not a tie-break. Terminal matching is a
/// heuristic Rust itself doesn't have — the language requires a bare ident to
/// be in scope — and it exists here only to recover references that arrived
/// through a glob (`use some_crate::*`), since a glob records no
/// ident-to-path mapping. Any reference brought in by an explicit `use` was
/// already resolved by step 1 of [`resolve_bare_ident`]. So reaching here
/// means nothing named the ident explicitly, and Rust says an item declared
/// or explicitly imported in the referencing crate shadows a glob import.
/// A same-root collision is still genuinely ambiguous, and still an error.
fn terminal_resolution(ident: &str, pool: &BTreeMap<TypePath, syn::Item>, home_root: Option<&str>) -> Resolution {
    let matches: Vec<TypePath> = pool.keys().filter(|p| p.terminal() == ident).cloned().collect();
    let home: Vec<TypePath> = match home_root {
        Some(root) => {
            matches.iter().filter(|p| p.segments().first().map(String::as_str) == Some(root)).cloned().collect()
        }
        None => Vec::new(),
    };
    let candidates = if home.is_empty() { matches } else { home };
    match candidates.len() {
        0 => Resolution::NotInPool,
        1 => Resolution::Resolved(candidates.into_iter().next().expect("len checked")),
        _ => Resolution::Ambiguous(candidates),
    }
}

/// Recursive walker over `syn::UseTree` — the shape `use a::{b, c::d as e, f::*}`
/// builds up.
fn walk_use_tree(tree: &UseTree, prefix: &mut Vec<String>, out: &mut FileImports) {
    match tree {
        UseTree::Path(p) => {
            prefix.push(p.ident.to_string());
            walk_use_tree(&p.tree, prefix, out);
            prefix.pop();
        }
        UseTree::Name(name) => {
            // `use foo::Bar;` — `name.ident == "Bar"`; prefix is `["foo"]`.
            let ident = name.ident.to_string();
            let mut segments = prefix.clone();
            segments.push(ident.clone());
            if let Ok(path) = TypePath::new(segments) {
                out.simple.insert(ident, path);
            }
        }
        UseTree::Rename(rename) => {
            // `use foo::Bar as Baz;` — local ident is `Baz`, canonical is
            // `prefix::Bar`.
            let canonical_ident = rename.ident.to_string();
            let local_ident = rename.rename.to_string();
            let mut segments = prefix.clone();
            segments.push(canonical_ident);
            if let Ok(path) = TypePath::new(segments) {
                out.simple.insert(local_ident, path);
            }
        }
        UseTree::Glob(_) => {
            // `use foo::bar::*;` — record the prefix; one-segment refs hit
            // `UnresolvedReference` with a hint that this glob may be the
            // missing source.
            if !prefix.is_empty()
                && let Ok(path) = TypePath::new(prefix.clone())
            {
                out.globs.insert(path);
            }
        }
        UseTree::Group(group) => {
            for inner in &group.items {
                walk_use_tree(inner, prefix, out);
            }
        }
    }
}

/// Strip generic args from a [`syn::Path`] and return the segment idents as
/// a vector. `Path<A, B>` → `["Path"]`; `foo::bar::Baz<u32>` →
/// `["foo", "bar", "Baz"]`.
fn path_segments(path: &Path) -> Vec<String> {
    path.segments.iter().map(|seg| seg.ident.to_string()).collect()
}

/// Canonicalize a referenced `syn::Path` against the file's imports.
///
/// `referenced_by` is the type whose field carries this reference — included
/// in errors for context.
///
/// Returns the canonical [`TypePath`] suitable for lookup in either the
/// pool (project-relative) or the external-types table (full canonical
/// path).
pub(crate) fn canonicalize(
    path: &Path,
    imports: &FileImports,
    referenced_by: &TypePath,
) -> Result<TypePath, EmitError> {
    let mut segments = path_segments(path);

    if segments.is_empty() {
        return Err(EmitError::UnresolvedReference {
            name: "<empty path>".to_string(),
            referenced_by: referenced_by.clone(),
        });
    }

    // Multi-segment path: take as-qualified, strip `crate::` for pool lookup.
    if segments.len() > 1 {
        if segments.first().map(String::as_str) == Some("crate") {
            segments.remove(0);
        }
        return TypePath::new(segments).map_err(|_| EmitError::UnresolvedReference {
            name: "<empty after crate:: stripped>".to_string(),
            referenced_by: referenced_by.clone(),
        });
    }

    // One-segment ident: consult imports.
    let ident = &segments[0];
    if let Some(path) = imports.resolve_ident(ident) {
        return Ok(path);
    }

    // Not in imports. If any glob imports are present, surface a hint —
    // the ident may live in one of those globs and we can't tell without
    // walking the imported crate's source.
    if !imports.globs.is_empty() {
        let globs_rendered: Vec<String> =
            imports.globs.iter().map(|p| format!("use {}::*;", p.segments().join("::"))).collect();
        return Err(EmitError::UnresolvedReference {
            name: format!(
                "`{ident}` (may come from {}; qualify the reference (e.g., chrono::{ident}) or replace the glob with \
                 an explicit `use`)",
                globs_rendered.join(", ")
            ),
            referenced_by: referenced_by.clone(),
        });
    }

    // Bare one-segment ident with no matching `use`: treat as a local type
    // at the crate root. The pool walker may have it; the lookup happens
    // at the call site. If neither pool nor external-types match, the
    // emitter surfaces `UnresolvedReference` later.
    TypePath::new(vec![ident.clone()])
        .map_err(|_| EmitError::UnresolvedReference { name: ident.clone(), referenced_by: referenced_by.clone() })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_file(src: &str) -> syn::File {
        syn::parse_str(src).expect("parse file")
    }

    /// Prepend the local-crate root, matching what the pool walker produces.
    fn rooted(segments: &[&str]) -> Vec<String> {
        std::iter::once("crate".to_string()).chain(segments.iter().map(|s| (*s).to_string())).collect()
    }

    /// An expected pool key in the local crate.
    fn tp(segments: &[&str]) -> TypePath {
        TypePath::new(rooted(segments)).expect("non-empty")
    }

    /// An expected pool key with an explicit root — for extra-root siblings.
    fn tp_in(segments: &[&str]) -> TypePath {
        TypePath::new(segments.iter().map(|s| (*s).to_string()).collect()).expect("non-empty")
    }

    fn parse_path(src: &str) -> Path {
        syn::parse_str(src).expect("parse path")
    }

    /// Synthetic pool rooted at the local crate, as `scan_src_dir` would key it.
    fn pool_from(entries: &[(&[&str], &str)]) -> BTreeMap<TypePath, syn::Item> {
        entries
            .iter()
            .map(|(segs, src)| {
                (TypePath::new(rooted(segs)).expect("non-empty"), syn::parse_str::<syn::Item>(src).expect("parse item"))
            })
            .collect()
    }

    /// Merge additional entries whose roots are given verbatim — used to model
    /// a `pool_extra_roots` sibling alongside the local crate.
    fn with_root(
        mut pool: BTreeMap<TypePath, syn::Item>,
        entries: &[(&[&str], &str)],
    ) -> BTreeMap<TypePath, syn::Item> {
        for (segs, src) in entries {
            let key = TypePath::new(segs.iter().map(|s| (*s).to_string()).collect()).expect("non-empty");
            pool.insert(key, syn::parse_str::<syn::Item>(src).expect("parse item"));
        }
        pool
    }

    fn imports_from(entries: &[(&[&str], &str)]) -> ModuleImports {
        let mut imports = ModuleImports::default();
        for (module, src) in entries {
            let file = parse_file(src);
            collect_module_imports(&file, &rooted(module), &mut imports);
        }
        imports
    }

    /// Imports for modules whose roots are given verbatim.
    fn imports_in(entries: &[(&[&str], &str)]) -> ModuleImports {
        let mut imports = ModuleImports::default();
        for (module, src) in entries {
            let file = parse_file(src);
            let prefix: Vec<String> = module.iter().map(|s| (*s).to_string()).collect();
            collect_module_imports(&file, &prefix, &mut imports);
        }
        imports
    }

    /// A written source path — NOT rooted; this is what appears in the code.
    fn seg(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| (*s).to_string()).collect()
    }

    /// A referencing module's canonical path in the local crate.
    fn md(parts: &[&str]) -> Vec<String> {
        rooted(parts)
    }

    // ── resolve_reference ─────────────────────────────────────────────────

    #[test]
    fn reference_resolves_relative_reexport_chain() {
        // The Pumice `VaultConfig` shape, which broke the first cut:
        //   api::v1::vault   `use crate::schema::VaultConfig;`        (the API site)
        //   schema (mod.rs)  `pub use vault::VaultConfig;`            (RELATIVE re-export)
        //   schema::vault    `pub struct VaultConfig { … }`           (the definition)
        //   vault            `pub struct VaultConfig { … }`           (an unrelated same-name type)
        // The bare `VaultConfig` referenced in api::v1::vault must resolve to
        // schema::vault::VaultConfig — through the `use` + relative re-export —
        // NOT to the sibling `vault::VaultConfig`.
        let pool = pool_from(&[
            (&["schema", "vault", "VaultConfig"], "pub struct VaultConfig { pub template: String }"),
            (&["vault", "VaultConfig"], "pub struct VaultConfig { pub enabled: bool }"),
        ]);
        let imports = imports_from(&[
            (&["api", "v1", "vault"], "use crate::schema::VaultConfig;"),
            (&["schema"], "pub use vault::VaultConfig;"),
        ]);
        let r = resolve_reference(&seg(&["VaultConfig"]), &md(&["api", "v1", "vault"]), &pool, &imports);
        assert_eq!(r, Resolution::Resolved(tp(&["schema", "vault", "VaultConfig"])), "got {r:?}");
    }

    #[test]
    fn reference_without_disambiguating_use_is_ambiguous() {
        // Same colliding pool, but the referencing module has no `use` for
        // `VaultConfig` — the resolver must report Ambiguous, never guess.
        let pool = pool_from(&[
            (&["schema", "vault", "VaultConfig"], "pub struct VaultConfig { pub template: String }"),
            (&["vault", "VaultConfig"], "pub struct VaultConfig { pub enabled: bool }"),
        ]);
        let imports = ModuleImports::default();
        let r = resolve_reference(&seg(&["VaultConfig"]), &md(&["api", "v1", "vault"]), &pool, &imports);
        match r {
            Resolution::Ambiguous(cands) => assert_eq!(cands.len(), 2, "got {cands:?}"),
            other => panic!("expected Ambiguous, got {other:?}"),
        }
    }

    #[test]
    fn reference_through_crate_absolute_reexport_chain() {
        // Same as the relative case but the facade re-exports with an absolute
        // `pub use crate::core::Foo;`.
        let pool = pool_from(&[(&["core", "Foo"], "pub struct Foo { pub x: u32 }")]);
        let imports = imports_from(&[(&["c"], "use crate::facade::Foo;"), (&["facade"], "pub use crate::core::Foo;")]);
        let r = resolve_reference(&seg(&["Foo"]), &md(&["c"]), &pool, &imports);
        assert_eq!(r, Resolution::Resolved(tp(&["core", "Foo"])), "got {r:?}");
    }

    #[test]
    fn cross_crate_use_hits_the_sibling_key_exactly() {
        // `use pumice_config::ThemePreference;` — a `pool_extra_roots`
        // sibling. Its key names its own crate, so the written path IS the
        // key and resolution is exact rather than a terminal guess.
        let pool = with_root(
            BTreeMap::new(),
            &[(&["pumice_config", "ui", "ThemePreference"], "pub enum ThemePreference { Light, Dark }")],
        );
        let imports = imports_from(&[(&["schema", "settings"], "use pumice_config::ThemePreference;")]);
        let r = resolve_reference(&seg(&["ThemePreference"]), &md(&["schema", "settings"]), &pool, &imports);
        assert_eq!(r, Resolution::Resolved(tp_in(&["pumice_config", "ui", "ThemePreference"])), "got {r:?}");
    }

    #[test]
    fn cross_crate_qualified_path_resolves_without_a_use() {
        // The form issue #84 wanted to work: name the sibling type outright.
        // Before rooting, `absolutize` returned None for any non-local first
        // segment and this fell through to terminal guessing.
        let pool =
            with_root(BTreeMap::new(), &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Error }")]);
        let r = resolve_reference(
            &seg(&["vaultpolish_core", "lint", "Severity"]),
            &md(&["api", "v1", "scan"]),
            &pool,
            &ModuleImports::default(),
        );
        assert_eq!(r, Resolution::Resolved(tp_in(&["vaultpolish_core", "lint", "Severity"])), "got {r:?}");
    }

    #[test]
    fn bare_ident_colliding_across_roots_takes_the_local_one() {
        // The reported #84 failure. A local mirror and a sibling type share a
        // terminal, and the reference arrives with nothing naming it — a glob
        // import, typically. Rust says a locally declared or explicitly
        // imported item shadows a glob, and a bare ident can never reach a
        // foreign crate's type unaided, so the local key is the only answer
        // that could be right. This used to be a build-failing Ambiguous.
        let pool = with_root(
            pool_from(&[(&["schema", "scan", "Severity"], "pub enum Severity { Error, Warning, Info }")]),
            &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Error }")],
        );
        let r = resolve_reference(&seg(&["Severity"]), &md(&["api", "v1", "scan"]), &pool, &ModuleImports::default());
        assert_eq!(r, Resolution::Resolved(tp(&["schema", "scan", "Severity"])), "got {r:?}");
    }

    #[test]
    fn a_sibling_referencing_itself_stays_in_its_own_crate() {
        // `crate::` is relative to whichever root the referencing module is
        // in. With flat keys, a sibling's own `use crate::lint::Severity`
        // resolved against the merged namespace where the local crate had
        // already won the key — silently yielding the wrong type's shape.
        let pool = with_root(
            pool_from(&[(&["lint", "Severity"], "pub enum Severity { Local }")]),
            &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Sibling }")],
        );
        let r = resolve_reference(
            &seg(&["crate", "lint", "Severity"]),
            &["vaultpolish_core".to_string(), "scan".to_string()],
            &pool,
            &ModuleImports::default(),
        );
        assert_eq!(r, Resolution::Resolved(tp_in(&["vaultpolish_core", "lint", "Severity"])), "got {r:?}");

        // And the same text written in the local crate still means the local one.
        let r =
            resolve_reference(&seg(&["crate", "lint", "Severity"]), &md(&["scan"]), &pool, &ModuleImports::default());
        assert_eq!(r, Resolution::Resolved(tp(&["lint", "Severity"])), "got {r:?}");
    }

    #[test]
    fn same_root_collision_is_still_ambiguous() {
        // Local preference only breaks ties ACROSS roots. Two same-named
        // types in one crate with nothing to disambiguate is a genuine
        // ambiguity that rustc would reject too, so it stays an error.
        let pool = with_root(
            pool_from(&[
                (&["a", "Severity"], "pub enum Severity { X }"),
                (&["b", "Severity"], "pub enum Severity { Y }"),
            ]),
            &[(&["vaultpolish_core", "lint", "Severity"], "pub enum Severity { Z }")],
        );
        let r = resolve_reference(&seg(&["Severity"]), &md(&["api"]), &pool, &ModuleImports::default());
        match r {
            Resolution::Ambiguous(cands) => {
                assert_eq!(cands.len(), 2, "only the two local candidates compete: {cands:?}");
                assert!(cands.iter().all(|p| p.segments()[0] == "crate"), "got {cands:?}");
            }
            other => panic!("expected Ambiguous, got {other:?}"),
        }
    }

    #[test]
    fn external_use_with_no_pool_match_is_not_in_pool() {
        // `use chrono::DateTime;` with no pool type sharing the terminal —
        // genuinely external, so no resolution.
        let pool = pool_from(&[(&["models", "Workout"], "pub struct Workout { pub id: u64 }")]);
        let imports = imports_from(&[(&["c"], "use chrono::DateTime;")]);
        let r = resolve_reference(&seg(&["DateTime"]), &md(&["c"]), &pool, &imports);
        assert_eq!(r, Resolution::NotInPool, "got {r:?}");
    }

    #[test]
    fn reference_unique_terminal_without_imports_resolves() {
        // No imports, bare ident, exactly one pool key with that terminal.
        let pool = pool_from(&[(&["schema", "backup", "BackupManifest"], "pub struct BackupManifest { pub v: u32 }")]);
        let r = resolve_reference(&seg(&["BackupManifest"]), &md(&["api"]), &pool, &ModuleImports::default());
        assert_eq!(r, Resolution::Resolved(tp(&["schema", "backup", "BackupManifest"])), "got {r:?}");
    }

    #[test]
    fn reference_qualified_crate_path_matches_exact_key() {
        let pool = pool_from(&[(&["models", "Workout"], "pub struct Workout { pub id: u64 }")]);
        let r =
            resolve_reference(&seg(&["crate", "models", "Workout"]), &md(&["api"]), &pool, &ModuleImports::default());
        assert_eq!(r, Resolution::Resolved(tp(&["models", "Workout"])), "got {r:?}");
    }

    // ── parse_imports ─────────────────────────────────────────────────────

    #[test]
    fn parse_simple_use() {
        let f = parse_file("use chrono::DateTime;");
        let imports = parse_imports(&f);
        assert_eq!(imports.simple.get("DateTime"), Some(&tp_in(&["chrono", "DateTime"])));
    }

    #[test]
    fn parse_use_with_rename() {
        let f = parse_file("use chrono::DateTime as Moment;");
        let imports = parse_imports(&f);
        assert_eq!(imports.simple.get("Moment"), Some(&tp_in(&["chrono", "DateTime"])));
        // The original ident isn't re-mapped.
        assert!(!imports.simple.contains_key("DateTime"));
    }

    #[test]
    fn parse_use_with_group() {
        let f = parse_file("use chrono::{DateTime, NaiveDate, NaiveTime};");
        let imports = parse_imports(&f);
        assert_eq!(imports.simple.get("DateTime"), Some(&tp_in(&["chrono", "DateTime"])));
        assert_eq!(imports.simple.get("NaiveDate"), Some(&tp_in(&["chrono", "NaiveDate"])));
        assert_eq!(imports.simple.get("NaiveTime"), Some(&tp_in(&["chrono", "NaiveTime"])));
    }

    #[test]
    fn parse_nested_group() {
        let f = parse_file("use foo::{bar::Baz, qux::{Quux, Quuux as Q}};");
        let imports = parse_imports(&f);
        assert_eq!(imports.simple.get("Baz"), Some(&tp_in(&["foo", "bar", "Baz"])));
        assert_eq!(imports.simple.get("Quux"), Some(&tp_in(&["foo", "qux", "Quux"])));
        assert_eq!(imports.simple.get("Q"), Some(&tp_in(&["foo", "qux", "Quuux"])));
    }

    #[test]
    fn parse_glob_import() {
        let f = parse_file("use chrono::*;");
        let imports = parse_imports(&f);
        assert!(imports.globs.contains(&tp_in(&["chrono"])));
        assert!(imports.simple.is_empty());
    }

    #[test]
    fn parse_multiple_glob_imports() {
        let f = parse_file("use chrono::*; use uuid::*;");
        let imports = parse_imports(&f);
        assert!(imports.globs.contains(&tp_in(&["chrono"])));
        assert!(imports.globs.contains(&tp_in(&["uuid"])));
    }

    // ── canonicalize ──────────────────────────────────────────────────────

    #[test]
    fn canonicalize_single_segment_via_imports() {
        let f = parse_file("use chrono::DateTime;");
        let imports = parse_imports(&f);
        let path = parse_path("DateTime");
        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
    }

    #[test]
    fn canonicalize_single_segment_via_rename() {
        let f = parse_file("use chrono::DateTime as Moment;");
        let imports = parse_imports(&f);
        let path = parse_path("Moment");
        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
    }

    #[test]
    fn canonicalize_unresolved_single_segment_falls_through() {
        // No imports, no globs — treated as a bare local ident.
        let f = parse_file("");
        let imports = parse_imports(&f);
        let path = parse_path("MyWorkout");
        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
        assert_eq!(resolved, tp_in(&["MyWorkout"]));
    }

    #[test]
    fn canonicalize_unresolved_with_glob_emits_hint() {
        let f = parse_file("use chrono::*;");
        let imports = parse_imports(&f);
        let path = parse_path("DateTime");
        let err = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap_err();
        match err {
            EmitError::UnresolvedReference { name, .. } => {
                assert!(name.contains("DateTime"), "name was: {name}");
                assert!(name.contains("chrono"), "name was: {name}");
                assert!(name.contains("glob") || name.contains("qualify"), "hint missing: {name}");
            }
            other => panic!("expected UnresolvedReference, got {other:?}"),
        }
    }

    #[test]
    fn canonicalize_multi_segment_taken_as_qualified() {
        let f = parse_file("");
        let imports = parse_imports(&f);
        let path = parse_path("chrono::DateTime");
        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
    }

    #[test]
    fn canonicalize_strips_crate_prefix() {
        let f = parse_file("");
        let imports = parse_imports(&f);
        let path = parse_path("crate::models::Workout");
        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
        // `crate::` stripped — pool keys are crate-relative.
        assert_eq!(resolved, tp_in(&["models", "Workout"]));
    }

    #[test]
    fn canonicalize_strips_generic_args() {
        let f = parse_file("");
        let imports = parse_imports(&f);
        let path = parse_path("chrono::DateTime<Utc>");
        let resolved = canonicalize(&path, &imports, &tp_in(&["Foo"])).unwrap();
        // Generic args don't affect the canonical name.
        assert_eq!(resolved, tp_in(&["chrono", "DateTime"]));
    }
}