1use std::collections::BTreeMap;
2
3use roaring::RoaringBitmap;
4use rustc_hash::{FxHashMap, FxHashSet};
5
6use super::{
7 CodeIndex, LinkageSnapshot, ReferenceId, SourceId, SymbolId, UnresolvedReason,
8 WorkspaceSnapshot,
9};
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub struct BoundedPathEdge {
13 pub source: SymbolId,
14 pub target: SymbolId,
15 pub reference: ReferenceId,
16}
17
18#[derive(Clone, Debug, Default, Eq, PartialEq)]
19pub struct BoundedPathCoverage {
20 pub total: usize,
21 pub decided: usize,
22 pub resolved: usize,
23 pub external: usize,
24 pub candidate: usize,
25 pub dynamic: usize,
26 pub manifest_blocked: usize,
27 pub unresolved: usize,
28 pub gap_reasons: BTreeMap<String, usize>,
29}
30
31impl BoundedPathCoverage {
32 pub fn percent(&self) -> usize {
33 self.decided
34 .saturating_mul(100)
35 .checked_div(self.total)
36 .unwrap_or(100)
37 }
38}
39
40#[derive(Clone, Debug, Default, Eq, PartialEq)]
41pub struct BoundedPathSearch {
42 pub path: Vec<BoundedPathEdge>,
43 pub coverage: BoundedPathCoverage,
44 pub depth_reached: usize,
45 pub explored_symbols: usize,
46 pub explored_edges: usize,
47 pub depth_limit_reached: bool,
48 pub symbol_limit_reached: bool,
49 pub edge_limit_reached: bool,
50}
51
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct BoundedPathLimits {
54 pub max_depth: usize,
55 pub max_symbols: usize,
56 pub max_edges: usize,
57}
58
59#[derive(Clone, Debug, Default, Eq, PartialEq)]
60pub struct BoundedPathScope {
61 sources: RoaringBitmap,
62}
63
64#[derive(Clone, Copy, Debug)]
65pub struct BoundedPathRequest<'a> {
66 pub from: SymbolId,
67 pub to: SymbolId,
68 pub relations: &'a [String],
69 pub avoid: &'a [SymbolId],
70 pub limits: BoundedPathLimits,
71 pub scope: &'a BoundedPathScope,
72}
73
74impl BoundedPathScope {
75 pub fn from_sources(sources: impl IntoIterator<Item = SourceId>) -> Self {
76 Self {
77 sources: sources
78 .into_iter()
79 .map(|source| source.file() as u32)
80 .collect(),
81 }
82 }
83
84 fn contains(&self, symbol: SymbolId) -> bool {
85 self.sources.contains(symbol.file() as u32)
86 }
87}
88
89#[derive(Default)]
90struct ReferenceClassifications {
91 external: FxHashSet<ReferenceId>,
92 candidate: FxHashMap<ReferenceId, &'static str>,
93 dynamic: FxHashMap<ReferenceId, &'static str>,
94 manifest_blocked: FxHashSet<ReferenceId>,
95 unresolved: FxHashMap<ReferenceId, UnresolvedReason>,
96}
97
98impl ReferenceClassifications {
99 fn from_linkage(linkage: &LinkageSnapshot) -> Self {
100 Self {
101 external: Self::external(linkage),
102 candidate: Self::candidate(linkage),
103 dynamic: Self::dynamic(linkage),
104 manifest_blocked: Self::manifest_blocked(linkage),
105 unresolved: Self::unresolved(linkage),
106 }
107 }
108
109 fn external(linkage: &LinkageSnapshot) -> FxHashSet<ReferenceId> {
110 linkage
111 .external
112 .iter()
113 .map(|reference| reference.reference)
114 .collect()
115 }
116
117 fn candidate(linkage: &LinkageSnapshot) -> FxHashMap<ReferenceId, &'static str> {
118 linkage
119 .candidates
120 .iter()
121 .map(|reference| (reference.reference, reference.reason.as_str()))
122 .collect()
123 }
124
125 fn dynamic(linkage: &LinkageSnapshot) -> FxHashMap<ReferenceId, &'static str> {
126 linkage
127 .dynamic
128 .iter()
129 .map(|reference| (reference.reference, reference.reason.as_str()))
130 .collect()
131 }
132
133 fn manifest_blocked(linkage: &LinkageSnapshot) -> FxHashSet<ReferenceId> {
134 linkage
135 .blocked
136 .iter()
137 .chain(linkage.manifest_blocked.iter())
138 .filter(|reference| reference.reason == UnresolvedReason::ManifestBlocked)
139 .map(|reference| reference.reference)
140 .collect()
141 }
142
143 fn unresolved(linkage: &LinkageSnapshot) -> FxHashMap<ReferenceId, UnresolvedReason> {
144 linkage
145 .unresolved
146 .iter()
147 .chain(
148 linkage
149 .blocked
150 .iter()
151 .filter(|reference| reference.reason != UnresolvedReason::ManifestBlocked),
152 )
153 .map(|reference| (reference.reference, reference.reason))
154 .collect()
155 }
156
157 fn tally_gap(&self, reference: ReferenceId, coverage: &mut BoundedPathCoverage) {
158 if self.external.contains(&reference) {
159 coverage.external += 1;
160 coverage.decided += 1;
161 } else if let Some(reason) = self.candidate.get(&reference) {
162 coverage.candidate += 1;
163 tally_reason(&mut coverage.gap_reasons, "candidate", reason);
164 } else if let Some(reason) = self.dynamic.get(&reference) {
165 coverage.dynamic += 1;
166 tally_reason(&mut coverage.gap_reasons, "dynamic", reason);
167 } else if self.manifest_blocked.contains(&reference) {
168 coverage.manifest_blocked += 1;
169 *coverage
170 .gap_reasons
171 .entry("manifest_blocked".to_string())
172 .or_default() += 1;
173 } else {
174 coverage.unresolved += 1;
175 let reason = self
176 .unresolved
177 .get(&reference)
178 .map_or("unclassified", UnresolvedReason::as_str);
179 tally_reason(&mut coverage.gap_reasons, "unresolved", reason);
180 }
181 }
182}
183
184fn tally_reason(reasons: &mut BTreeMap<String, usize>, category: &str, reason: &str) {
185 *reasons.entry(format!("{category}:{reason}")).or_default() += 1;
186}
187
188pub struct BoundedPathEngine<'a> {
189 read_index: &'a super::LinkageReadIndex,
190 classifications: ReferenceClassifications,
191}
192
193impl<'a> BoundedPathEngine<'a> {
194 pub fn new(index: &CodeIndex, linkage: &'a LinkageSnapshot) -> Option<Self> {
195 if linkage.index_generation != index.generation {
196 return None;
197 }
198 Some(Self {
199 read_index: linkage.read_index.get()?,
200 classifications: ReferenceClassifications::from_linkage(linkage),
201 })
202 }
203
204 pub fn search(&self, request: BoundedPathRequest<'_>) -> Option<BoundedPathSearch> {
205 PathTraversal::new(
206 self.read_index,
207 &self.classifications,
208 request.relations,
209 request.avoid,
210 request.limits,
211 request.scope,
212 )?
213 .run(request.from, request.to)
214 }
215}
216
217impl WorkspaceSnapshot {
218 pub fn bounded_path(
219 &self,
220 from: SymbolId,
221 to: SymbolId,
222 relations: &[String],
223 limits: BoundedPathLimits,
224 scope: &BoundedPathScope,
225 ) -> Option<BoundedPathSearch> {
226 bounded_path(
227 &self.index,
228 &self.linkage,
229 BoundedPathRequest {
230 from,
231 to,
232 relations,
233 avoid: &[],
234 limits,
235 scope,
236 },
237 )
238 }
239}
240
241pub fn bounded_path(
242 index: &CodeIndex,
243 linkage: &LinkageSnapshot,
244 request: BoundedPathRequest<'_>,
245) -> Option<BoundedPathSearch> {
246 BoundedPathEngine::new(index, linkage)?.search(request)
247}
248
249struct PathTraversal<'a> {
250 read_index: &'a super::LinkageReadIndex,
251 relations: Vec<&'a str>,
252 scope: &'a BoundedPathScope,
253 max_depth: usize,
254 max_symbols: usize,
255 max_edges: usize,
256 classifications: &'a ReferenceClassifications,
257 avoided: RoaringBitmap,
258 visited: RoaringBitmap,
259 predecessors: FxHashMap<u32, (u32, ReferenceId)>,
260 search: BoundedPathSearch,
261}
262
263impl<'a> PathTraversal<'a> {
264 fn new(
265 read_index: &'a super::LinkageReadIndex,
266 classifications: &'a ReferenceClassifications,
267 relations: &'a [String],
268 avoid: &'a [SymbolId],
269 limits: BoundedPathLimits,
270 scope: &'a BoundedPathScope,
271 ) -> Option<Self> {
272 let mut seen_relations = FxHashSet::default();
273 let avoided = avoid
274 .iter()
275 .filter_map(|symbol| read_index.ordinal(symbol))
276 .collect();
277 Some(Self {
278 read_index,
279 relations: relations
280 .iter()
281 .map(String::as_str)
282 .filter(|relation| seen_relations.insert(*relation))
283 .collect(),
284 scope,
285 max_depth: limits.max_depth,
286 max_symbols: limits.max_symbols,
287 max_edges: limits.max_edges,
288 classifications,
289 avoided,
290 visited: RoaringBitmap::new(),
291 predecessors: FxHashMap::default(),
292 search: BoundedPathSearch::default(),
293 })
294 }
295
296 fn run(mut self, from: SymbolId, to: SymbolId) -> Option<BoundedPathSearch> {
297 if !self.scope.contains(from) || !self.scope.contains(to) {
298 return None;
299 }
300 let from_ordinal = self.read_index.ordinal(&from)?;
301 let to_ordinal = self.read_index.ordinal(&to)?;
302 if self.avoided.contains(from_ordinal) || self.avoided.contains(to_ordinal) {
303 return None;
304 }
305 let mut frontier = RoaringBitmap::new();
306 self.visited.insert(from_ordinal);
307 frontier.insert(from_ordinal);
308 if from_ordinal != to_ordinal {
309 self.walk(&mut frontier, to_ordinal);
310 }
311 self.search.explored_symbols = self.visited.len() as usize;
312 if self.visited.contains(to_ordinal) {
313 self.search.path = reconstruct_path(
314 self.read_index,
315 &self.predecessors,
316 from_ordinal,
317 to_ordinal,
318 )?;
319 }
320 Some(self.search)
321 }
322
323 fn walk(&mut self, frontier: &mut RoaringBitmap, to_ordinal: u32) {
324 'search: for depth in 0..=self.max_depth {
325 self.search.depth_reached = depth;
326 let mut next = RoaringBitmap::new();
327 for source_ordinal in frontier.iter() {
328 let relations = self.relations_for(source_ordinal);
329 for relation in relations {
330 let outgoing_count = self.read_index.outgoing(source_ordinal, &relation).len();
331 for reference_index in 0..outgoing_count {
332 if self.search.coverage.total >= self.max_edges {
333 self.search.edge_limit_reached = true;
334 break 'search;
335 }
336 let reference =
337 self.read_index.outgoing(source_ordinal, &relation)[reference_index];
338 if self.visit_reference(source_ordinal, reference, depth, &mut next)
339 == Some(to_ordinal)
340 {
341 self.search.depth_reached = depth + 1;
342 break 'search;
343 }
344 }
345 }
346 }
347 if next.is_empty() {
348 break;
349 }
350 *frontier = next;
351 }
352 }
353
354 fn relations_for(&self, source_ordinal: u32) -> Vec<String> {
355 if self.relations.is_empty() {
356 self.read_index
357 .outgoing_relations(source_ordinal)
358 .map(str::to_string)
359 .collect()
360 } else {
361 self.relations
362 .iter()
363 .map(|relation| (*relation).to_string())
364 .collect()
365 }
366 }
367
368 fn visit_reference(
369 &mut self,
370 source_ordinal: u32,
371 reference: ReferenceId,
372 depth: usize,
373 next: &mut RoaringBitmap,
374 ) -> Option<u32> {
375 self.search.coverage.total += 1;
376 let Some(target) = self.read_index.resolved_target(&reference).copied() else {
377 self.classifications
378 .tally_gap(reference, &mut self.search.coverage);
379 return None;
380 };
381 let Some(target_ordinal) = self.read_index.ordinal(&target) else {
382 self.tally_missing_ordinal();
383 return None;
384 };
385 self.search.coverage.resolved += 1;
386 self.search.coverage.decided += 1;
387 self.search.explored_edges += 1;
388 if !self.scope.contains(target) {
389 return None;
390 }
391 if self.avoided.contains(target_ordinal) {
392 return None;
393 }
394 if depth >= self.max_depth {
395 if !self.visited.contains(target_ordinal) {
396 self.search.depth_limit_reached = true;
397 }
398 return None;
399 }
400 if self.visited.contains(target_ordinal) {
401 return None;
402 }
403 if self.visited.len() as usize >= self.max_symbols {
404 self.search.symbol_limit_reached = true;
405 return None;
406 }
407 self.visited.insert(target_ordinal);
408 self.predecessors
409 .insert(target_ordinal, (source_ordinal, reference));
410 next.insert(target_ordinal);
411 Some(target_ordinal)
412 }
413
414 fn tally_missing_ordinal(&mut self) {
415 self.search.coverage.unresolved += 1;
416 *self
417 .search
418 .coverage
419 .gap_reasons
420 .entry("missing_symbol_ordinal".to_string())
421 .or_default() += 1;
422 }
423}
424
425fn reconstruct_path(
426 read_index: &super::LinkageReadIndex,
427 predecessors: &FxHashMap<u32, (u32, ReferenceId)>,
428 from: u32,
429 to: u32,
430) -> Option<Vec<BoundedPathEdge>> {
431 let mut path = Vec::new();
432 let mut current = to;
433 while current != from {
434 let (previous, reference) = predecessors.get(¤t).copied()?;
435 path.push(BoundedPathEdge {
436 source: read_index.symbol(previous)?,
437 target: read_index.symbol(current)?,
438 reference,
439 });
440 current = previous;
441 }
442 path.reverse();
443 Some(path)
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::snapshot::{
450 CandidateReason, CandidateReference, CandidateScope, ChangeOverlay, CodeIndex,
451 DynamicReason, DynamicReference, ExternalReference, ExternalReferenceOrigin, LinkageEdge,
452 LinkageReadIndexHandle, LinkageSnapshot, ReferenceRecord, ResolutionEvidence,
453 ResourceGeneration, SourceCatalog, SymbolRecord, UnresolvedReference, WorkspaceTimings,
454 };
455
456 fn path_snapshot(
457 symbols: Vec<SymbolRecord>,
458 references: Vec<ReferenceRecord>,
459 edges: Vec<LinkageEdge>,
460 ordinals: Vec<(u32, SymbolId)>,
461 ) -> WorkspaceSnapshot {
462 let generation = ResourceGeneration::new(1);
463 let index = CodeIndex::with_references(generation, generation, symbols, references);
464 let read_index =
465 LinkageReadIndexHandle::from_edges_with_ordinals(&edges, &index.references, ordinals);
466 let mut linkage = LinkageSnapshot::new(generation, generation, edges.len(), 0);
467 linkage.resolved = edges;
468 linkage.read_index = read_index;
469 WorkspaceSnapshot {
470 generation,
471 catalog: SourceCatalog::new(generation, Vec::new()),
472 index,
473 linkage,
474 changes: ChangeOverlay::new(generation, generation, generation, Vec::new()),
475 timings: WorkspaceTimings::default(),
476 }
477 }
478
479 #[test]
480 fn selected_source_scope_blocks_cross_root_detours_with_sparse_ordinals() {
481 let first_source = SourceId::at(0);
482 let other_source = SourceId::at(1);
483 let from = SymbolId::at(0, 0);
484 let to = SymbolId::at(0, 1);
485 let bridge = SymbolId::at(1, 0);
486 let first = ReferenceId::at(0, 0);
487 let second = ReferenceId::at(1, 0);
488 let snapshot = path_snapshot(
489 vec![
490 SymbolRecord::new(from, first_source, "from", "fn"),
491 SymbolRecord::new(to, first_source, "to", "fn"),
492 SymbolRecord::new(bridge, other_source, "bridge", "fn"),
493 ],
494 vec![
495 ReferenceRecord::new(first, first_source, from, bridge.to_string(), "calls", None),
496 ReferenceRecord::new(second, other_source, bridge, to.to_string(), "calls", None),
497 ],
498 vec![
499 LinkageEdge::new(first, bridge),
500 LinkageEdge::new(second, to),
501 ],
502 vec![(7, from), (2_000_000, bridge), (u32::MAX - 1, to)],
503 );
504 let limits = BoundedPathLimits {
505 max_depth: 4,
506 max_symbols: 10,
507 max_edges: 10,
508 };
509 let selected = BoundedPathScope::from_sources([first_source]);
510 let scoped = snapshot
511 .bounded_path(from, to, &["calls".to_string()], limits, &selected)
512 .expect("scoped path search");
513 assert!(scoped.path.is_empty(), "{scoped:?}");
514 assert_eq!(scoped.coverage.resolved, 1, "{scoped:?}");
515 assert_eq!(scoped.coverage.decided, 1, "{scoped:?}");
516
517 let all_sources = BoundedPathScope::from_sources([first_source, other_source]);
518 let unscoped = snapshot
519 .bounded_path(from, to, &["calls".to_string()], limits, &all_sources)
520 .expect("all-roots path search");
521 assert_eq!(unscoped.path.len(), 2, "{unscoped:?}");
522 assert_eq!(
523 snapshot
524 .linkage
525 .read_index
526 .get()
527 .expect("path read index")
528 .active_symbol_slots(),
529 3,
530 "sparse stable ordinals must not allocate historical holes"
531 );
532 }
533
534 #[test]
535 fn coverage_counts_every_unlinked_reference_category() {
536 let source = SourceId::at(0);
537 let from = SymbolId::at(0, 0);
538 let to = SymbolId::at(0, 1);
539 let ids = (0..5)
540 .map(|index| ReferenceId::at(0, index))
541 .collect::<Vec<_>>();
542 let references = ids
543 .iter()
544 .enumerate()
545 .map(|(index, id)| {
546 ReferenceRecord::new(*id, source, from, format!("missing:{index}"), "calls", None)
547 })
548 .collect();
549 let mut snapshot = path_snapshot(
550 vec![
551 SymbolRecord::new(from, source, "from", "fn"),
552 SymbolRecord::new(to, source, "to", "fn"),
553 ],
554 references,
555 Vec::new(),
556 vec![(11, from), (97, to)],
557 );
558 snapshot.linkage.external.push(ExternalReference::new(
559 ids[0],
560 "external",
561 ExternalReferenceOrigin::Dependency,
562 ));
563 snapshot.linkage.candidates.push(CandidateReference::new(
564 ids[1],
565 vec![to],
566 CandidateReason::MultipleTargets,
567 CandidateScope::Local,
568 ResolutionEvidence::NameMatch,
569 ));
570 snapshot.linkage.dynamic.push(DynamicReference::new(
571 ids[2],
572 "dynamic",
573 DynamicReason::RuntimeMutation,
574 Vec::new(),
575 ));
576 snapshot.linkage.blocked.push(UnresolvedReference::new(
577 ids[3],
578 "blocked",
579 UnresolvedReason::ManifestBlocked,
580 ));
581 snapshot.linkage.unresolved.push(UnresolvedReference::new(
582 ids[4],
583 "missing",
584 UnresolvedReason::NoCandidate,
585 ));
586
587 let search = snapshot
588 .bounded_path(
589 from,
590 to,
591 &["calls".to_string()],
592 BoundedPathLimits {
593 max_depth: 4,
594 max_symbols: 10,
595 max_edges: 10,
596 },
597 &BoundedPathScope::from_sources([source]),
598 )
599 .expect("coverage path search");
600 assert_eq!(search.coverage.total, 5, "{search:?}");
601 assert_eq!(search.coverage.decided, 1, "{search:?}");
602 assert_eq!(search.coverage.external, 1, "{search:?}");
603 assert_eq!(search.coverage.candidate, 1, "{search:?}");
604 assert_eq!(search.coverage.dynamic, 1, "{search:?}");
605 assert_eq!(search.coverage.manifest_blocked, 1, "{search:?}");
606 assert_eq!(search.coverage.unresolved, 1, "{search:?}");
607 }
608
609 #[test]
610 fn edge_budget_stops_inside_a_large_indexed_adjacency() {
611 let source = SourceId::at(0);
612 let from = SymbolId::at(0, 0);
613 let sink = SymbolId::at(0, 1);
614 let to = SymbolId::at(0, 2);
615 let references = (0..2_048)
616 .map(|index| {
617 let id = ReferenceId::at(0, index);
618 ReferenceRecord::new(id, source, from, sink.to_string(), "calls", None)
619 })
620 .collect::<Vec<_>>();
621 let edges = references
622 .iter()
623 .map(|reference| LinkageEdge::new(reference.id, sink))
624 .collect();
625 let snapshot = path_snapshot(
626 vec![
627 SymbolRecord::new(from, source, "from", "fn"),
628 SymbolRecord::new(sink, source, "sink", "fn"),
629 SymbolRecord::new(to, source, "to", "fn"),
630 ],
631 references,
632 edges,
633 vec![(1, from), (2, sink), (3, to)],
634 );
635
636 let search = snapshot
637 .bounded_path(
638 from,
639 to,
640 &["calls".to_string()],
641 BoundedPathLimits {
642 max_depth: 4,
643 max_symbols: 10,
644 max_edges: 3,
645 },
646 &BoundedPathScope::from_sources([source]),
647 )
648 .expect("budgeted path search");
649 assert_eq!(search.coverage.total, 3, "{search:?}");
650 assert_eq!(search.explored_edges, 3, "{search:?}");
651 assert!(search.edge_limit_reached, "{search:?}");
652 }
653
654 #[test]
655 fn prepared_engine_reuses_linkage_classifications_across_searches() {
656 let source = SourceId::at(0);
657 let from = SymbolId::at(0, 0);
658 let middle = SymbolId::at(0, 1);
659 let to = SymbolId::at(0, 2);
660 let first = ReferenceId::at(0, 0);
661 let second = ReferenceId::at(0, 1);
662 let snapshot = path_snapshot(
663 vec![
664 SymbolRecord::new(from, source, "from", "fn"),
665 SymbolRecord::new(middle, source, "middle", "fn"),
666 SymbolRecord::new(to, source, "to", "fn"),
667 ],
668 vec![
669 ReferenceRecord::new(first, source, from, middle.to_string(), "calls", None),
670 ReferenceRecord::new(second, source, middle, to.to_string(), "calls", None),
671 ],
672 vec![
673 LinkageEdge::new(first, middle),
674 LinkageEdge::new(second, to),
675 ],
676 vec![(1, from), (2, middle), (3, to)],
677 );
678 let scope = BoundedPathScope::from_sources([source]);
679 let relations = vec!["calls".to_string()];
680 let limits = BoundedPathLimits {
681 max_depth: 4,
682 max_symbols: 10,
683 max_edges: 10,
684 };
685 let engine =
686 BoundedPathEngine::new(&snapshot.index, &snapshot.linkage).expect("path engine");
687
688 let first_search = engine
689 .search(BoundedPathRequest {
690 from,
691 to: middle,
692 relations: &relations,
693 avoid: &[],
694 limits,
695 scope: &scope,
696 })
697 .expect("first search");
698 let second_search = engine
699 .search(BoundedPathRequest {
700 from,
701 to,
702 relations: &relations,
703 avoid: &[],
704 limits,
705 scope: &scope,
706 })
707 .expect("second search");
708 let avoided_search = engine
709 .search(BoundedPathRequest {
710 from,
711 to,
712 relations: &relations,
713 avoid: &[middle],
714 limits,
715 scope: &scope,
716 })
717 .expect("search avoiding the mandatory boundary");
718
719 assert_eq!(first_search.path.len(), 1);
720 assert_eq!(second_search.path.len(), 2);
721 assert!(avoided_search.path.is_empty(), "{avoided_search:?}");
722 }
723}