ontogen-ts 0.1.0

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
//! 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 derived from where each item was defined
//! (`["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`):
//!   take as-qualified. `crate::` prefix is stripped before pool lookup so
//!   keys stay project-relative.
//! - 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.
//!
//! `#[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 unique terminal-segment match. A
/// multi-segment reference matches only 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 {
    // Strip a leading `crate::`; pool keys are crate-relative.
    let canonical: &[String] =
        if segments.first().map(String::as_str) == Some("crate") { &segments[1..] } else { segments };
    match canonical {
        [] => Resolution::NotInPool,
        [only] => resolve_bare_ident(only, module, pool, imports, 0),
        multi => match TypePath::new(multi.to_vec()) {
            Ok(tp) if pool.contains_key(&tp) => Resolution::Resolved(tp),
            // A qualified path we don't have as a definition key — external,
            // or a re-export path we don't follow for multi-segment refs.
            _ => 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 unique terminal-segment match across the whole pool.
    terminal_resolution(ident, pool)
}

/// 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 crate-relative path. `None`
    // means it's rooted at another crate (`use chrono::DateTime`, or a
    // `pool_extra_roots` sibling like `use pumice_config::ThemePreference`).
    // We can't tell a true-external crate from an in-pool sibling — the
    // extra-root scan strips the crate name, so a sibling's types are keyed
    // under their own module path (`ui::ThemePreference`, not
    // `pumice_config::ui::ThemePreference`). The only available resolution is
    // a unique terminal match: it finds the sibling type and leaves a genuine
    // external (no pool key with that terminal) as `NotInPool`.
    let Some(abs) = absolutize(target, in_module, pool, imports) else {
        return terminal_resolution(&leaf, pool);
    };
    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)
}

/// Normalize a `use` path written in `in_module` to an absolute crate-relative
/// segment vector. Returns `None` when the path is rooted at an external crate
/// (not `crate`/`self`/`super`, and not a submodule of `in_module`).
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() {
        "crate" => Some(rest.to_vec()),
        "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 either a submodule of `in_module` (the
            // 2018-edition relative form, `pub use vault::X` inside `schema`)
            // or an external crate. Prefer relative when the submodule exists.
            let mut candidate_module = in_module.to_vec();
            candidate_module.push(first.clone());
            if is_known_module(&candidate_module, pool, imports) { Some([in_module, target].concat()) } else { 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.
fn terminal_resolution(ident: &str, pool: &BTreeMap<TypePath, syn::Item>) -> Resolution {
    let matches: Vec<TypePath> = pool.keys().filter(|p| p.terminal() == ident).cloned().collect();
    match matches.len() {
        0 => Resolution::NotInPool,
        1 => Resolution::Resolved(matches.into_iter().next().expect("len checked")),
        _ => Resolution::Ambiguous(matches),
    }
}

/// 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")
    }

    fn tp(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")
    }

    fn pool_from(entries: &[(&[&str], &str)]) -> BTreeMap<TypePath, syn::Item> {
        entries
            .iter()
            .map(|(segs, src)| {
                let key = TypePath::new(segs.iter().map(|s| (*s).to_string()).collect()).expect("non-empty");
                (key, syn::parse_str::<syn::Item>(src).expect("parse item"))
            })
            .collect()
    }

    fn imports_from(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
    }

    fn seg(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| (*s).to_string()).collect()
    }

    // ── 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"]), &seg(&["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"]), &seg(&["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"]), &seg(&["c"]), &pool, &imports);
        assert_eq!(r, Resolution::Resolved(tp(&["core", "Foo"])), "got {r:?}");
    }

    #[test]
    fn cross_crate_use_resolves_via_unique_terminal() {
        // `use pumice_config::ThemePreference;` — `pumice_config` is a
        // `pool_extra_roots` sibling crate, so its type is in the pool keyed
        // under its own module (`ui::ThemePreference`), with the crate name
        // stripped. The flat pool can't distinguish this from a true-external
        // crate, so it resolves via a unique terminal match.
        let pool = pool_from(&[(&["ui", "ThemePreference"], "pub enum ThemePreference { Light, Dark }")]);
        let imports = imports_from(&[(&["schema", "settings"], "use pumice_config::ThemePreference;")]);
        let r = resolve_reference(&seg(&["ThemePreference"]), &seg(&["schema", "settings"]), &pool, &imports);
        assert_eq!(r, Resolution::Resolved(tp(&["ui", "ThemePreference"])), "got {r:?}");
    }

    #[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"]), &seg(&["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"]), &seg(&["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"]), &seg(&["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(&["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(&["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(&["chrono", "DateTime"])));
        assert_eq!(imports.simple.get("NaiveDate"), Some(&tp(&["chrono", "NaiveDate"])));
        assert_eq!(imports.simple.get("NaiveTime"), Some(&tp(&["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(&["foo", "bar", "Baz"])));
        assert_eq!(imports.simple.get("Quux"), Some(&tp(&["foo", "qux", "Quux"])));
        assert_eq!(imports.simple.get("Q"), Some(&tp(&["foo", "qux", "Quuux"])));
    }

    #[test]
    fn parse_glob_import() {
        let f = parse_file("use chrono::*;");
        let imports = parse_imports(&f);
        assert!(imports.globs.contains(&tp(&["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(&["chrono"])));
        assert!(imports.globs.contains(&tp(&["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(&["Foo"])).unwrap();
        assert_eq!(resolved, tp(&["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(&["Foo"])).unwrap();
        assert_eq!(resolved, tp(&["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(&["Foo"])).unwrap();
        assert_eq!(resolved, tp(&["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(&["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(&["Foo"])).unwrap();
        assert_eq!(resolved, tp(&["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(&["Foo"])).unwrap();
        // `crate::` stripped — pool keys are crate-relative.
        assert_eq!(resolved, tp(&["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(&["Foo"])).unwrap();
        // Generic args don't affect the canonical name.
        assert_eq!(resolved, tp(&["chrono", "DateTime"]));
    }
}