1use std::collections::VecDeque;
21
22use fallow_types::discover::FileId;
23use fixedbitset::FixedBitSet;
24use rustc_hash::FxHashMap;
25
26use super::ModuleGraph;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ImportPathHop {
31 pub from: FileId,
33 pub to: FileId,
35 pub all_type_only: bool,
38 pub import_span_start: Option<u32>,
44 pub dynamic: bool,
48}
49
50impl ModuleGraph {
51 #[must_use]
59 pub fn shortest_import_path(&self, from: FileId, to: FileId) -> Option<Vec<ImportPathHop>> {
60 self.shortest_import_path_over(from, to, false)
61 }
62
63 #[must_use]
68 pub fn shortest_eager_import_path(
69 &self,
70 from: FileId,
71 to: FileId,
72 ) -> Option<Vec<ImportPathHop>> {
73 self.shortest_import_path_over(from, to, true)
74 }
75
76 fn shortest_import_path_over(
77 &self,
78 from: FileId,
79 to: FileId,
80 eager_only: bool,
81 ) -> Option<Vec<ImportPathHop>> {
82 if from == to {
83 return Some(Vec::new());
84 }
85 let capacity = self.modules.len();
86 if from.0 as usize >= capacity || to.0 as usize >= capacity {
87 return None;
88 }
89
90 let mut visited = FixedBitSet::with_capacity(capacity);
91 visited.insert(from.0 as usize);
92 let mut predecessor: FxHashMap<FileId, ImportPathHop> = FxHashMap::default();
93 let mut queue: VecDeque<FileId> = VecDeque::new();
94 queue.push_back(from);
95
96 while let Some(current) = queue.pop_front() {
97 for hop in self.ordered_outgoing_hops(current, eager_only) {
98 let idx = hop.to.0 as usize;
99 if idx >= capacity || visited.contains(idx) {
100 continue;
101 }
102 visited.insert(idx);
103 predecessor.insert(hop.to, hop);
104 if hop.to == to {
105 return Some(rebuild_path(&predecessor, from, to));
106 }
107 queue.push_back(hop.to);
108 }
109 }
110 None
111 }
112
113 fn ordered_outgoing_hops(&self, file_id: FileId, eager_only: bool) -> Vec<ImportPathHop> {
119 let mut hops: Vec<ImportPathHop> = self
120 .outgoing_symbol_edges(file_id)
121 .filter(|&(target, _)| target != file_id)
122 .filter_map(|(target, symbols)| {
123 let eager = symbols.iter().find(|s| s.is_eager_value());
124 if eager_only && eager.is_none() {
125 return None;
126 }
127 let all_type_only = !symbols.is_empty() && symbols.iter().all(|s| s.is_type_only);
128 let anchor = if eager_only {
129 eager
130 } else {
131 symbols
132 .iter()
133 .find(|s| !s.is_type_only)
134 .or_else(|| symbols.first())
135 };
136 Some(ImportPathHop {
137 from: file_id,
138 to: target,
139 all_type_only,
140 import_span_start: anchor.map(|s| s.import_span.start),
141 dynamic: !all_type_only && eager.is_none(),
142 })
143 })
144 .collect();
145 hops.sort_unstable_by_key(|hop| (hop.to.0, hop.all_type_only, hop.import_span_start));
146 hops.dedup_by_key(|hop| hop.to);
147 hops
148 }
149}
150
151fn rebuild_path(
153 predecessor: &FxHashMap<FileId, ImportPathHop>,
154 from: FileId,
155 to: FileId,
156) -> Vec<ImportPathHop> {
157 let mut reversed = Vec::new();
158 let mut cursor = to;
159 while cursor != from {
160 let Some(&hop) = predecessor.get(&cursor) else {
161 break;
162 };
163 reversed.push(hop);
164 cursor = hop.from;
165 }
166 reversed.reverse();
167 reversed
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
174 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
175 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
176 use std::path::PathBuf;
177
178 fn module_path(id: u32) -> PathBuf {
179 PathBuf::from(format!("/p/src/m{id}.ts"))
180 }
181
182 fn import(target: u32, span_start: u32, type_only: bool) -> ResolvedImport {
183 ResolvedImport {
184 info: ImportInfo {
185 source: format!("./m{target}"),
186 imported_name: ImportedName::Named("value".to_string()),
187 local_name: "value".to_string(),
188 is_type_only: type_only,
189 is_type_only_star: false,
190 from_style: false,
191 span: oxc_span::Span::new(span_start, span_start + 10),
192 source_span: oxc_span::Span::default(),
193 },
194 target: ResolveResult::InternalModule(FileId(target)),
195 }
196 }
197
198 fn value_export() -> ExportInfo {
199 ExportInfo {
200 name: ExportName::Named("value".to_string()),
201 local_name: Some("value".to_string()),
202 is_type_only: false,
203 visibility: VisibilityTag::None,
204 expected_unused_reason: None,
205 span: oxc_span::Span::new(0, 20),
206 members: vec![],
207 is_side_effect_used: false,
208 super_class: None,
209 deprecated: false,
210 deprecated_reason: None,
211 }
212 }
213
214 fn graph_with_edges(module_count: u32, edges: &[(u32, &[u32])]) -> ModuleGraph {
218 graph_with_typed_edges(module_count, edges, &[])
219 }
220
221 fn graph_with_typed_edges(
224 module_count: u32,
225 edges: &[(u32, &[u32])],
226 type_only_edges: &[(u32, u32)],
227 ) -> ModuleGraph {
228 let files: Vec<DiscoveredFile> = (0..module_count)
229 .map(|id| DiscoveredFile {
230 id: FileId(id),
231 path: module_path(id),
232 size_bytes: 10,
233 })
234 .collect();
235 let entry_points = vec![EntryPoint {
236 path: module_path(0),
237 source: EntryPointSource::PackageJsonMain,
238 }];
239 let resolved: Vec<ResolvedModule> = (0..module_count)
240 .map(|id| {
241 let resolved_imports = edges
242 .iter()
243 .find(|(source, _)| *source == id)
244 .map(|(source, targets)| {
245 targets
246 .iter()
247 .map(|&target| {
248 import(
249 target,
250 source * 100 + target,
251 type_only_edges.contains(&(*source, target)),
252 )
253 })
254 .collect()
255 })
256 .unwrap_or_default();
257 ResolvedModule {
258 file_id: FileId(id),
259 path: module_path(id),
260 resolved_imports,
261 exports: vec![value_export()].into(),
262 ..Default::default()
263 }
264 })
265 .collect();
266 ModuleGraph::build(&resolved, &entry_points, &files)
267 }
268
269 #[test]
270 fn same_module_is_zero_hops_not_unreachable() {
271 let graph = graph_with_edges(2, &[(0, &[1])]);
272 assert_eq!(
273 graph.shortest_import_path(FileId(0), FileId(0)),
274 Some(vec![])
275 );
276 }
277
278 #[test]
279 fn unreachable_target_has_no_path() {
280 let graph = graph_with_edges(3, &[(0, &[1])]);
281 assert_eq!(graph.shortest_import_path(FileId(0), FileId(2)), None);
282 assert_eq!(graph.shortest_import_path(FileId(1), FileId(0)), None);
284 }
285
286 #[test]
287 fn direct_import_is_one_hop_with_its_import_span() {
288 let graph = graph_with_edges(2, &[(0, &[1])]);
289 let path = graph
290 .shortest_import_path(FileId(0), FileId(1))
291 .expect("direct import is reachable");
292 assert_eq!(path.len(), 1);
293 assert_eq!(path[0].from, FileId(0));
294 assert_eq!(path[0].to, FileId(1));
295 assert!(!path[0].all_type_only);
296 assert_eq!(path[0].import_span_start, Some(1));
297 }
298
299 #[test]
300 fn breadth_first_prefers_the_shorter_route() {
301 let graph = graph_with_edges(
304 6,
305 &[(0, &[1, 2]), (1, &[5]), (2, &[3]), (3, &[4]), (4, &[5])],
306 );
307 let path = graph
308 .shortest_import_path(FileId(0), FileId(5))
309 .expect("target is reachable");
310 assert_eq!(
311 path.iter().map(|hop| hop.to).collect::<Vec<_>>(),
312 vec![FileId(1), FileId(5)]
313 );
314 }
315
316 #[test]
317 fn equal_length_routes_resolve_to_the_smallest_file_id_sequence() {
318 let graph = graph_with_edges(4, &[(0, &[2, 1]), (1, &[3]), (2, &[3])]);
321 let path = graph
322 .shortest_import_path(FileId(0), FileId(3))
323 .expect("target is reachable");
324 assert_eq!(
325 path.iter().map(|hop| hop.to).collect::<Vec<_>>(),
326 vec![FileId(1), FileId(3)]
327 );
328 }
329
330 #[test]
331 fn a_cycle_does_not_stall_the_walk() {
332 let graph = graph_with_edges(4, &[(0, &[1]), (1, &[2]), (2, &[1, 3])]);
333 let path = graph
334 .shortest_import_path(FileId(0), FileId(3))
335 .expect("target is reachable behind a cycle");
336 assert_eq!(path.len(), 3);
337 }
338
339 #[test]
340 fn out_of_range_ids_reach_nothing() {
341 let graph = graph_with_edges(2, &[(0, &[1])]);
342 assert_eq!(graph.shortest_import_path(FileId(0), FileId(9)), None);
343 assert_eq!(graph.shortest_import_path(FileId(9), FileId(1)), None);
344 }
345
346 #[test]
347 fn a_type_only_hop_is_reported_not_skipped() {
348 let graph = graph_with_typed_edges(2, &[(0, &[1])], &[(0, 1)]);
349 let path = graph
350 .shortest_import_path(FileId(0), FileId(1))
351 .expect("a type-only import is still a route");
352 assert_eq!(path.len(), 1);
353 assert!(path[0].all_type_only);
354 assert!(!path[0].dynamic, "a type-only hop is not a lazy load");
355 assert_eq!(
356 graph.shortest_eager_import_path(FileId(0), FileId(1)),
357 None,
358 "import type loads nothing, so no eager route exists"
359 );
360 }
361
362 #[test]
363 fn the_eager_route_matches_the_plain_route_over_static_edges() {
364 let graph = graph_with_edges(3, &[(0, &[1]), (1, &[2])]);
365 let plain = graph.shortest_import_path(FileId(0), FileId(2));
366 assert_eq!(
367 graph.shortest_eager_import_path(FileId(0), FileId(2)),
368 plain
369 );
370 assert!(plain.expect("reachable").iter().all(|hop| !hop.dynamic));
371 }
372}