sqry_core/graph/unified/bind/alias.rs
1//! Alias derivation for the Phase 2 binding plane.
2//!
3//! An `AliasEntry` records a single alias declaration (`from_symbol` in the
4//! scope resolves to the declaration at `to_symbol`). Wildcard imports
5//! (`from module import *`) are represented with `is_wildcard = true` and a
6//! sentinel `to_symbol` — the resolver forwards these through the target
7//! module's exports rather than the alias table directly.
8
9use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::graph::unified::build::phase4e_binding::BindingEdgeIndex;
14use crate::graph::unified::edge::kind::EdgeKind;
15use crate::graph::unified::mutation_target::GraphMutationTarget;
16use crate::graph::unified::node::id::NodeId;
17use crate::graph::unified::node::kind::NodeKind;
18use crate::graph::unified::string::id::StringId;
19
20use super::scope::{ScopeArena, ScopeId};
21
22/// Dense handle into an `AliasTable`.
23///
24/// The id is the position of the entry in the sorted `entries` slice, so
25/// `AliasEntryId(k)` is valid as long as the table hasn't been replaced.
26/// `AliasTable::get(id)` performs a single bounds-checked slice index.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct AliasEntryId(pub u32);
29
30/// One alias declaration.
31///
32/// Records the scope where the alias is declared, the local name the alias
33/// introduces (`from_symbol`), the target name the alias points to
34/// (`to_symbol`), the `EdgeId` of the `Imports` edge that produced this
35/// entry, and whether this was a wildcard import.
36///
37/// The `id` field is the position of this entry in the owning `AliasTable`'s
38/// sorted slice after `derive_aliases` has run. It is assigned by the sort
39/// and must not be mutated by callers.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct AliasEntry {
42 /// Stable dense handle for this entry within its owning `AliasTable`.
43 /// Assigned after sorting; equals `AliasEntryId(idx)` where `idx` is the
44 /// position of this entry in the sorted entries slice.
45 pub id: AliasEntryId,
46 /// Scope that contains this alias declaration.
47 pub scope: ScopeId,
48 /// The local symbol name introduced by the alias (e.g., `baz` from
49 /// `use foo::bar as baz`).
50 pub from_symbol: StringId,
51 /// The qualified target name the alias resolves to (e.g., `foo::bar`).
52 ///
53 /// Falls back to the local name if `qualified_name` is unset on the
54 /// import node. In that case `to_symbol == from_symbol`.
55 pub to_symbol: StringId,
56 /// `NodeId` of the `Import` node that this entry was derived from.
57 pub import_node: NodeId,
58 /// Whether this is a wildcard import (`from module import *`).
59 pub is_wildcard: bool,
60}
61
62/// Content-addressed alias lookup table.
63///
64/// Entries are sorted by `(scope, from_symbol)` for `O(log n)` binary-search
65/// lookup via [`AliasTable::resolve_alias`]. Wildcard entries are included in
66/// [`AliasTable::aliases_in`] but are skipped by `resolve_alias` — the
67/// resolver dispatches wildcards through the target module's exports.
68///
69/// # Serialization
70///
71/// Only `entries` is persisted. The `by_scope` index is derived from
72/// `entries` on deserialization and cannot drift out of sync with the entries
73/// slice. This keeps the V9 snapshot footprint minimal and eliminates the
74/// dual-write hazard.
75#[derive(Debug, Clone, Default)]
76pub struct AliasTable {
77 /// All entries, sorted by `(scope, from_symbol)`.
78 entries: Vec<AliasEntry>,
79 /// Per-scope range index mapping a `ScopeId` to its `[start, end)` slice
80 /// in `entries`. Populated after the entries are sorted; **not** persisted.
81 by_scope: HashMap<ScopeId, (u32, u32)>,
82}
83
84impl AliasTable {
85 /// Creates an empty `AliasTable`.
86 #[must_use]
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 /// Returns the total number of alias entries.
92 #[must_use]
93 pub fn len(&self) -> usize {
94 self.entries.len()
95 }
96
97 /// Returns `true` if the table contains no entries.
98 #[must_use]
99 pub fn is_empty(&self) -> bool {
100 self.entries.is_empty()
101 }
102
103 /// Returns the full sorted entries slice.
104 ///
105 /// Useful for V9 persistence walkers, debug dumps, stats collection, and
106 /// any future consumer that needs to iterate the entire table.
107 #[must_use]
108 pub fn entries(&self) -> &[AliasEntry] {
109 &self.entries
110 }
111
112 /// Looks up an alias entry by its dense `AliasEntryId`.
113 ///
114 /// Returns `None` if `id.0 >= self.len()`. The id is the position of the
115 /// entry in the sorted slice, assigned during `derive_aliases`. It is
116 /// stable for the lifetime of this table instance.
117 ///
118 /// Used by `ResolutionStep::FollowAlias { alias: AliasEntryId }` in the
119 /// P2U07 resolver to recover the full `AliasEntry` for a recorded step.
120 #[must_use]
121 pub fn get(&self, id: AliasEntryId) -> Option<&AliasEntry> {
122 self.entries.get(id.0 as usize)
123 }
124
125 /// Returns all alias entries declared in `scope`, including wildcard
126 /// entries. Returns an empty slice when no aliases belong to `scope`.
127 #[must_use]
128 pub fn aliases_in(&self, scope: ScopeId) -> &[AliasEntry] {
129 match self.by_scope.get(&scope) {
130 Some(&(start, end)) => &self.entries[start as usize..end as usize],
131 None => &[],
132 }
133 }
134
135 /// Resolves `symbol` within `scope` to its target name.
136 ///
137 /// Returns `None` when no matching non-wildcard alias exists. Wildcard
138 /// entries are deliberately skipped — the resolver dispatches wildcards
139 /// through the target module's exports at query time.
140 ///
141 /// The lookup uses binary search on the sorted `(scope, from_symbol)`
142 /// order, so it is `O(log n)` in the number of aliases in `scope`.
143 #[must_use]
144 pub fn resolve_alias(&self, scope: ScopeId, symbol: StringId) -> Option<StringId> {
145 let slice = self.aliases_in(scope);
146 // slice is already scope-filtered, so sorting by from_symbol alone is valid.
147 slice
148 .binary_search_by(|entry| entry.from_symbol.cmp(&symbol))
149 .ok()
150 .map(|idx| &slice[idx])
151 .filter(|entry| !entry.is_wildcard)
152 .map(|entry| entry.to_symbol)
153 }
154
155 /// Applies `keep` to every entry's `import_node`, dropping entries whose
156 /// import-node fails the predicate.
157 ///
158 /// After filtering, the `id` of each surviving entry is reassigned to its
159 /// new position in the sorted `entries` slice, and the `by_scope` range
160 /// index is rebuilt from scratch so `resolve_alias` / `aliases_in` stay
161 /// consistent. The relative order of surviving entries is preserved, so
162 /// the table remains sorted by `(scope, from_symbol)`.
163 ///
164 /// This is the mutation entry point used by the Gate 0c `NodeIdBearing`
165 /// impl (A2 §K row K.A12). Callers that hold the table behind an `Arc`
166 /// must reach it through `Arc::make_mut` before invoking this method.
167 ///
168 /// `#[allow(dead_code)]` mirrors the NodeIdBearing trait itself: Gate 0b
169 /// lands the scaffolding and unit tests, Gate 0c adds the production
170 /// call site in `RebuildGraph::finalize()`.
171 #[allow(dead_code)]
172 pub(crate) fn retain_by_node(&mut self, keep: &dyn Fn(NodeId) -> bool) {
173 self.entries.retain(|entry| keep(entry.import_node));
174 for (idx, entry) in self.entries.iter_mut().enumerate() {
175 entry.id = AliasEntryId(u32::try_from(idx).expect("alias count fits u32"));
176 }
177 self.rebuild_index();
178 }
179
180 /// Rewrite every `StringId` stored on every entry through `remap`,
181 /// replacing any ID that appears as a key with its canonical value.
182 ///
183 /// This is the mutation entry point used by Gate 0c's finalize step 1
184 /// (StringId canonicalisation). `from_symbol` is part of the
185 /// `(scope, from_symbol)` sort key, so after rewrite the entries are
186 /// re-sorted by `(scope, from_symbol)` and `by_scope` is rebuilt so
187 /// `resolve_alias` / `aliases_in` keep returning consistent results.
188 ///
189 /// The relative order within a `(scope, from_symbol)` group is
190 /// preserved by using a stable sort. Empty `remap` is a no-op.
191 ///
192 /// Callers that hold the table behind an `Arc` must reach it through
193 /// `Arc::make_mut` before invoking this method.
194 #[allow(dead_code)]
195 pub(crate) fn rewrite_string_ids_through_remap(
196 &mut self,
197 remap: &std::collections::HashMap<StringId, StringId>,
198 ) {
199 if remap.is_empty() {
200 return;
201 }
202 let mut changed = false;
203 for entry in &mut self.entries {
204 if let Some(&canon) = remap.get(&entry.from_symbol) {
205 entry.from_symbol = canon;
206 changed = true;
207 }
208 if let Some(&canon) = remap.get(&entry.to_symbol) {
209 entry.to_symbol = canon;
210 changed = true;
211 }
212 }
213 if !changed {
214 return;
215 }
216 // `from_symbol` may have collapsed onto a different canonical ID,
217 // so re-establish the (scope, from_symbol) sort order and rebuild
218 // the scope range index. Use a stable sort to preserve the prior
219 // order within each (scope, from_symbol) group.
220 self.entries
221 .sort_by(|a, b| (a.scope, a.from_symbol).cmp(&(b.scope, b.from_symbol)));
222 for (idx, entry) in self.entries.iter_mut().enumerate() {
223 entry.id = AliasEntryId(u32::try_from(idx).expect("alias count fits u32"));
224 }
225 self.rebuild_index();
226 }
227
228 /// Rebuilds the `by_scope` range index from `self.entries`.
229 ///
230 /// Called after deserialization (where `by_scope` is not persisted) and
231 /// after constructing an `AliasTable` from a raw `Vec<AliasEntry>`. The
232 /// entries must already be sorted by `(scope, from_symbol)`.
233 fn rebuild_index(&mut self) {
234 debug_assert!(
235 self.entries.windows(2).all(|w| w[0].scope <= w[1].scope),
236 "rebuild_index requires entries sorted by scope ascending"
237 );
238 self.by_scope.clear();
239 let mut cursor = 0u32;
240 while (cursor as usize) < self.entries.len() {
241 let scope = self.entries[cursor as usize].scope;
242 let start = cursor;
243 while (cursor as usize) < self.entries.len()
244 && self.entries[cursor as usize].scope == scope
245 {
246 cursor += 1;
247 }
248 self.by_scope.insert(scope, (start, cursor));
249 }
250 }
251}
252
253// ---------------------------------------------------------------------------
254// Serde: persist only `entries`; rebuild `by_scope` on deserialize.
255// ---------------------------------------------------------------------------
256
257impl Serialize for AliasTable {
258 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
259 // Serialize as a newtype wrapping just the entries Vec.
260 self.entries.serialize(serializer)
261 }
262}
263
264impl<'de> Deserialize<'de> for AliasTable {
265 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
266 let entries = Vec::<AliasEntry>::deserialize(deserializer)?;
267 let mut table = AliasTable {
268 entries,
269 by_scope: HashMap::new(),
270 };
271 table.rebuild_index();
272 Ok(table)
273 }
274}
275
276/// Iterates every `Import` node via the `by_kind` index and, for each one,
277/// looks up its incoming `Imports` edges from the pre-built
278/// `BindingEdgeIndex::imports_by_target` map (O(1) per node). The source of
279/// each such edge is the importing scope's node; the enclosing scope is
280/// resolved via the precomputed `node_to_scope` map built from
281/// `ScopeArena::iter()`.
282///
283/// For `use foo::bar as baz;` the `alias` field on the edge carries `baz` and
284/// the import node's `qualified_name` carries `foo::bar`. For
285/// `from module import *`, `alias` is `None`, `is_wildcard` is `true`, and the
286/// import node's name is the sentinel `*`.
287///
288/// When the import node's `qualified_name` is `None`, `to_symbol` falls back
289/// to the plain `name` field — i.e., `to_symbol == from_symbol`.
290///
291/// Returns `(AliasTable, aliases_with_invalid_scope)` where the second value
292/// is the number of `Import` nodes whose enclosing scope could not be resolved.
293///
294/// # Performance
295///
296/// The `edge_index` parameter replaces the per-import `edges_to()` calls that
297/// previously triggered O(E) delta scans, turning per-node edge lookup from
298/// O(E) to O(1).
299pub(crate) fn derive_aliases<G: GraphMutationTarget>(
300 graph: &G,
301 scopes: &ScopeArena,
302 edge_index: &BindingEdgeIndex,
303) -> (AliasTable, u64) {
304 // Build a NodeId → ScopeId map for O(1) enclosing-scope lookup.
305 // Uses ScopeArena::iter() so each slot's real generation is used; avoids
306 // the generation=1 hardcode that a manual 0..slot_count loop would require.
307 let node_to_scope = build_node_to_scope_map(scopes);
308
309 let mut raw: Vec<AliasEntry> = Vec::new();
310 let mut invalid_scope_count: u64 = 0;
311
312 // Iterate every Import node and read its incoming Imports edges from
313 // the pre-built index (O(1) per node). Imports edges point FROM the
314 // importing scope/module TO the import node.
315 for &import_node_id in graph.indices().by_kind(NodeKind::Import) {
316 let Some(import_entry) = graph.nodes().get(import_node_id) else {
317 continue;
318 };
319
320 // Look up pre-indexed incoming Imports edges for this import node.
321 let Some(incoming) = edge_index.imports_by_target.get(&import_node_id) else {
322 continue;
323 };
324
325 for (source_node_id, edge_kind) in incoming {
326 let EdgeKind::Imports { alias, is_wildcard } = edge_kind else {
327 continue;
328 };
329
330 // Resolve the enclosing scope.
331 let scope = match node_to_scope.get(source_node_id).copied() {
332 Some(id) => id,
333 None => {
334 // The source node has no corresponding scope. Emit the
335 // entry with INVALID scope and count it for observability.
336 invalid_scope_count += 1;
337 ScopeId::INVALID
338 }
339 };
340
341 // `alias` holds the local name (e.g., `baz` in `use foo::bar as baz`).
342 // When no alias is given, use the import node's own name.
343 let from_symbol = alias.unwrap_or(import_entry.name);
344
345 // The target qualified name comes from the import node's qualified_name
346 // field, falling back to the plain name if not set — i.e., to_symbol
347 // equals from_symbol in the no-qualified-name case.
348 let to_symbol = import_entry.qualified_name.unwrap_or(import_entry.name);
349
350 // Placeholder id; will be overwritten after sorting.
351 raw.push(AliasEntry {
352 id: AliasEntryId(0),
353 scope,
354 from_symbol,
355 to_symbol,
356 import_node: import_node_id,
357 is_wildcard: *is_wildcard,
358 });
359 }
360 }
361
362 // Sort by (scope, from_symbol) to enable binary-search lookup.
363 raw.sort_by(|a, b| (a.scope, a.from_symbol).cmp(&(b.scope, b.from_symbol)));
364
365 // Assign stable ids: the position in the sorted slice IS the AliasEntryId.
366 for (idx, entry) in raw.iter_mut().enumerate() {
367 entry.id = AliasEntryId(u32::try_from(idx).expect("alias count fits u32"));
368 }
369
370 // Build the per-scope range index via the shared helper so the logic lives
371 // in exactly one place (deserialization also calls rebuild_index).
372 let mut table = AliasTable {
373 entries: raw,
374 by_scope: HashMap::new(),
375 };
376 table.rebuild_index();
377
378 (table, invalid_scope_count)
379}
380
381/// Builds a `HashMap<NodeId, ScopeId>` from the scope arena so that
382/// `derive_aliases` can resolve the enclosing scope for any node in O(1).
383///
384/// Uses `ScopeArena::iter()` so each slot's real generation is used and the
385/// generation=1 hardcode that a manual `0..slot_count` loop would require is
386/// avoided.
387fn build_node_to_scope_map(scopes: &ScopeArena) -> HashMap<NodeId, ScopeId> {
388 scopes.iter().map(|(id, scope)| (scope.node, id)).collect()
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::graph::unified::node::id::NodeId;
395 use crate::graph::unified::string::id::StringId;
396
397 fn make_scope(index: u32) -> ScopeId {
398 ScopeId::new(index, 1)
399 }
400
401 fn make_node() -> NodeId {
402 NodeId::new(0, 1)
403 }
404
405 fn make_entry(scope: ScopeId, from: u32, to: u32, wildcard: bool) -> AliasEntry {
406 AliasEntry {
407 id: AliasEntryId(0), // patched by derive_aliases; set manually in unit tests
408 scope,
409 from_symbol: StringId::new(from),
410 to_symbol: StringId::new(to),
411 import_node: make_node(),
412 is_wildcard: wildcard,
413 }
414 }
415
416 /// Build an `AliasTable` directly from a pre-sorted entries slice (for
417 /// unit tests that don't want to go through `derive_aliases`).
418 fn table_from_sorted(mut entries: Vec<AliasEntry>) -> AliasTable {
419 for (idx, e) in entries.iter_mut().enumerate() {
420 e.id = AliasEntryId(u32::try_from(idx).unwrap());
421 }
422 let mut table = AliasTable {
423 entries,
424 by_scope: HashMap::new(),
425 };
426 table.rebuild_index();
427 table
428 }
429
430 #[test]
431 fn empty_table_lookup_returns_none() {
432 let table = AliasTable::new();
433 assert!(table.is_empty());
434 let scope = make_scope(0);
435 assert!(table.aliases_in(scope).is_empty());
436 assert_eq!(table.resolve_alias(scope, StringId::new(0)), None);
437 }
438
439 #[test]
440 fn binary_search_resolve_alias() {
441 let scope = make_scope(0);
442 let entries = vec![
443 make_entry(scope, 10, 100, false),
444 make_entry(scope, 20, 200, false),
445 ];
446 let table = table_from_sorted(entries);
447 assert_eq!(
448 table.resolve_alias(scope, StringId::new(10)),
449 Some(StringId::new(100))
450 );
451 assert_eq!(
452 table.resolve_alias(scope, StringId::new(20)),
453 Some(StringId::new(200))
454 );
455 assert_eq!(table.resolve_alias(scope, StringId::new(30)), None);
456 }
457
458 #[test]
459 fn wildcard_skipped_by_resolve() {
460 let scope = make_scope(0);
461 let entries = vec![make_entry(scope, 5, 99, true)];
462 let table = table_from_sorted(entries);
463 assert_eq!(table.resolve_alias(scope, StringId::new(5)), None);
464 assert!(
465 table.aliases_in(scope).iter().any(|e| e.is_wildcard),
466 "wildcard entry visible via aliases_in"
467 );
468 }
469
470 #[test]
471 fn get_by_alias_entry_id_round_trips() {
472 let scope = make_scope(0);
473 let entries = vec![
474 make_entry(scope, 10, 100, false),
475 make_entry(scope, 20, 200, false),
476 ];
477 let table = table_from_sorted(entries);
478
479 // Ids are assigned by position after sort.
480 let e0 = table.get(AliasEntryId(0)).expect("id 0 must exist");
481 let e1 = table.get(AliasEntryId(1)).expect("id 1 must exist");
482
483 assert_eq!(e0.from_symbol, StringId::new(10));
484 assert_eq!(e0.to_symbol, StringId::new(100));
485 assert_eq!(e0.id, AliasEntryId(0));
486
487 assert_eq!(e1.from_symbol, StringId::new(20));
488 assert_eq!(e1.to_symbol, StringId::new(200));
489 assert_eq!(e1.id, AliasEntryId(1));
490
491 assert!(table.get(AliasEntryId(2)).is_none());
492 }
493
494 #[test]
495 fn entries_accessor_returns_full_slice() {
496 let scope = make_scope(0);
497 let entries = vec![
498 make_entry(scope, 1, 10, false),
499 make_entry(scope, 2, 20, false),
500 make_entry(scope, 3, 30, true),
501 ];
502 let table = table_from_sorted(entries);
503 assert_eq!(table.entries().len(), 3);
504 }
505
506 #[test]
507 fn postcard_round_trip_preserves_entries_and_rebuilds_by_scope() {
508 let s_a = make_scope(1);
509 let s_b = make_scope(2);
510 let entries = vec![
511 make_entry(s_a, 10, 100, false),
512 make_entry(s_a, 11, 101, false),
513 make_entry(s_b, 20, 200, true),
514 ];
515 let original = table_from_sorted(entries);
516
517 let bytes = postcard::to_allocvec(&original).expect("serialize");
518 let restored: AliasTable = postcard::from_bytes(&bytes).expect("deserialize");
519
520 assert_eq!(
521 restored.entries().len(),
522 original.entries().len(),
523 "entry count must survive round-trip"
524 );
525 assert_eq!(
526 restored.entries(),
527 original.entries(),
528 "entries must be byte-equal after round-trip"
529 );
530
531 // Verify by_scope was rebuilt correctly: resolve_alias must work.
532 assert_eq!(
533 restored.resolve_alias(s_a, StringId::new(10)),
534 Some(StringId::new(100)),
535 "resolve_alias on restored table must return correct target for s_a entry 0"
536 );
537 assert_eq!(
538 restored.resolve_alias(s_a, StringId::new(11)),
539 Some(StringId::new(101)),
540 "resolve_alias on restored table must return correct target for s_a entry 1"
541 );
542 // Wildcard entry in s_b is not resolved by resolve_alias.
543 assert_eq!(
544 restored.resolve_alias(s_b, StringId::new(20)),
545 None,
546 "wildcard entry must not be returned by resolve_alias after round-trip"
547 );
548 assert!(
549 restored.aliases_in(s_b).iter().any(|e| e.is_wildcard),
550 "wildcard entry in s_b must survive round-trip"
551 );
552 }
553
554 #[test]
555 fn aliases_in_partition_by_scope() {
556 let s_a = make_scope(1);
557 let s_b = make_scope(2);
558 let s_c = make_scope(3);
559
560 let from_x = StringId::new(10);
561 let from_y = StringId::new(11);
562 let to_1 = StringId::new(20);
563 let to_2 = StringId::new(21);
564
565 // Six entries across three scopes, two entries each.
566 // Build unsorted and let table_from_sorted sort them so the sort matters.
567 let mut entries = vec![
568 // s_b entries (will sort after s_a, before s_c by ScopeId ordering)
569 AliasEntry {
570 id: AliasEntryId(0),
571 scope: s_b,
572 from_symbol: from_x,
573 to_symbol: to_1,
574 import_node: make_node(),
575 is_wildcard: false,
576 },
577 AliasEntry {
578 id: AliasEntryId(0),
579 scope: s_b,
580 from_symbol: from_y,
581 to_symbol: to_2,
582 import_node: make_node(),
583 is_wildcard: false,
584 },
585 // s_c entries
586 AliasEntry {
587 id: AliasEntryId(0),
588 scope: s_c,
589 from_symbol: from_x,
590 to_symbol: to_2,
591 import_node: make_node(),
592 is_wildcard: false,
593 },
594 AliasEntry {
595 id: AliasEntryId(0),
596 scope: s_c,
597 from_symbol: from_y,
598 to_symbol: to_1,
599 import_node: make_node(),
600 is_wildcard: false,
601 },
602 // s_a entries (will sort first)
603 AliasEntry {
604 id: AliasEntryId(0),
605 scope: s_a,
606 from_symbol: from_x,
607 to_symbol: to_2,
608 import_node: make_node(),
609 is_wildcard: false,
610 },
611 AliasEntry {
612 id: AliasEntryId(0),
613 scope: s_a,
614 from_symbol: from_y,
615 to_symbol: to_1,
616 import_node: make_node(),
617 is_wildcard: false,
618 },
619 ];
620 entries.sort_by(|a, b| (a.scope, a.from_symbol).cmp(&(b.scope, b.from_symbol)));
621 let table = table_from_sorted(entries);
622
623 // Each scope must see exactly its own 2 entries.
624 let b_entries = table.aliases_in(s_b);
625 assert_eq!(b_entries.len(), 2, "s_b must have exactly 2 entries");
626 assert!(
627 b_entries.iter().all(|e| e.scope == s_b),
628 "s_b slice must only contain s_b entries"
629 );
630
631 let a_entries = table.aliases_in(s_a);
632 assert_eq!(a_entries.len(), 2, "s_a must have exactly 2 entries");
633 assert!(
634 a_entries.iter().all(|e| e.scope == s_a),
635 "s_a slice must only contain s_a entries"
636 );
637
638 let c_entries = table.aliases_in(s_c);
639 assert_eq!(c_entries.len(), 2, "s_c must have exactly 2 entries");
640 assert!(
641 c_entries.iter().all(|e| e.scope == s_c),
642 "s_c slice must only contain s_c entries"
643 );
644
645 // Scope isolation: same from_symbol in different scopes resolves to different targets.
646 let resolved_a = table.resolve_alias(s_a, from_x);
647 let resolved_b = table.resolve_alias(s_b, from_x);
648 assert!(resolved_a.is_some(), "s_a must resolve from_x");
649 assert!(resolved_b.is_some(), "s_b must resolve from_x");
650 assert_ne!(
651 resolved_a, resolved_b,
652 "same from_symbol in different scopes must resolve to different targets"
653 );
654 }
655}