Skip to main content

code_moniker_workspace/snapshot/
path.rs

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(&current).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}