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>,
43}
44
45impl ModuleGraph {
46 #[must_use]
54 pub fn shortest_import_path(&self, from: FileId, to: FileId) -> Option<Vec<ImportPathHop>> {
55 if from == to {
56 return Some(Vec::new());
57 }
58 let capacity = self.modules.len();
59 if from.0 as usize >= capacity || to.0 as usize >= capacity {
60 return None;
61 }
62
63 let mut visited = FixedBitSet::with_capacity(capacity);
64 visited.insert(from.0 as usize);
65 let mut predecessor: FxHashMap<FileId, ImportPathHop> = FxHashMap::default();
66 let mut queue: VecDeque<FileId> = VecDeque::new();
67 queue.push_back(from);
68
69 while let Some(current) = queue.pop_front() {
70 for hop in self.ordered_outgoing_hops(current) {
71 let idx = hop.to.0 as usize;
72 if idx >= capacity || visited.contains(idx) {
73 continue;
74 }
75 visited.insert(idx);
76 predecessor.insert(hop.to, hop);
77 if hop.to == to {
78 return Some(rebuild_path(&predecessor, from, to));
79 }
80 queue.push_back(hop.to);
81 }
82 }
83 None
84 }
85
86 fn ordered_outgoing_hops(&self, file_id: FileId) -> Vec<ImportPathHop> {
91 let mut hops: Vec<ImportPathHop> = self
92 .outgoing_edge_summaries(file_id)
93 .filter(|&(target, _, _)| target != file_id)
94 .map(|(target, all_type_only, import_span_start)| ImportPathHop {
95 from: file_id,
96 to: target,
97 all_type_only,
98 import_span_start,
99 })
100 .collect();
101 hops.sort_unstable_by_key(|hop| (hop.to.0, hop.all_type_only, hop.import_span_start));
102 hops.dedup_by_key(|hop| hop.to);
103 hops
104 }
105}
106
107fn rebuild_path(
109 predecessor: &FxHashMap<FileId, ImportPathHop>,
110 from: FileId,
111 to: FileId,
112) -> Vec<ImportPathHop> {
113 let mut reversed = Vec::new();
114 let mut cursor = to;
115 while cursor != from {
116 let Some(&hop) = predecessor.get(&cursor) else {
117 break;
118 };
119 reversed.push(hop);
120 cursor = hop.from;
121 }
122 reversed.reverse();
123 reversed
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
130 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
131 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
132 use std::path::PathBuf;
133
134 fn module_path(id: u32) -> PathBuf {
135 PathBuf::from(format!("/p/src/m{id}.ts"))
136 }
137
138 fn import(target: u32, span_start: u32, type_only: bool) -> ResolvedImport {
139 ResolvedImport {
140 info: ImportInfo {
141 source: format!("./m{target}"),
142 imported_name: ImportedName::Named("value".to_string()),
143 local_name: "value".to_string(),
144 is_type_only: type_only,
145 is_type_only_star: false,
146 from_style: false,
147 span: oxc_span::Span::new(span_start, span_start + 10),
148 source_span: oxc_span::Span::default(),
149 },
150 target: ResolveResult::InternalModule(FileId(target)),
151 }
152 }
153
154 fn value_export() -> ExportInfo {
155 ExportInfo {
156 name: ExportName::Named("value".to_string()),
157 local_name: Some("value".to_string()),
158 is_type_only: false,
159 visibility: VisibilityTag::None,
160 expected_unused_reason: None,
161 span: oxc_span::Span::new(0, 20),
162 members: vec![],
163 is_side_effect_used: false,
164 super_class: None,
165 deprecated: false,
166 deprecated_reason: None,
167 }
168 }
169
170 fn graph_with_edges(module_count: u32, edges: &[(u32, &[u32])]) -> ModuleGraph {
174 graph_with_typed_edges(module_count, edges, &[])
175 }
176
177 fn graph_with_typed_edges(
180 module_count: u32,
181 edges: &[(u32, &[u32])],
182 type_only_edges: &[(u32, u32)],
183 ) -> ModuleGraph {
184 let files: Vec<DiscoveredFile> = (0..module_count)
185 .map(|id| DiscoveredFile {
186 id: FileId(id),
187 path: module_path(id),
188 size_bytes: 10,
189 })
190 .collect();
191 let entry_points = vec![EntryPoint {
192 path: module_path(0),
193 source: EntryPointSource::PackageJsonMain,
194 }];
195 let resolved: Vec<ResolvedModule> = (0..module_count)
196 .map(|id| {
197 let resolved_imports = edges
198 .iter()
199 .find(|(source, _)| *source == id)
200 .map(|(source, targets)| {
201 targets
202 .iter()
203 .map(|&target| {
204 import(
205 target,
206 source * 100 + target,
207 type_only_edges.contains(&(*source, target)),
208 )
209 })
210 .collect()
211 })
212 .unwrap_or_default();
213 ResolvedModule {
214 file_id: FileId(id),
215 path: module_path(id),
216 resolved_imports,
217 exports: vec![value_export()].into(),
218 ..Default::default()
219 }
220 })
221 .collect();
222 ModuleGraph::build(&resolved, &entry_points, &files)
223 }
224
225 #[test]
226 fn same_module_is_zero_hops_not_unreachable() {
227 let graph = graph_with_edges(2, &[(0, &[1])]);
228 assert_eq!(
229 graph.shortest_import_path(FileId(0), FileId(0)),
230 Some(vec![])
231 );
232 }
233
234 #[test]
235 fn unreachable_target_has_no_path() {
236 let graph = graph_with_edges(3, &[(0, &[1])]);
237 assert_eq!(graph.shortest_import_path(FileId(0), FileId(2)), None);
238 assert_eq!(graph.shortest_import_path(FileId(1), FileId(0)), None);
240 }
241
242 #[test]
243 fn direct_import_is_one_hop_with_its_import_span() {
244 let graph = graph_with_edges(2, &[(0, &[1])]);
245 let path = graph
246 .shortest_import_path(FileId(0), FileId(1))
247 .expect("direct import is reachable");
248 assert_eq!(path.len(), 1);
249 assert_eq!(path[0].from, FileId(0));
250 assert_eq!(path[0].to, FileId(1));
251 assert!(!path[0].all_type_only);
252 assert_eq!(path[0].import_span_start, Some(1));
253 }
254
255 #[test]
256 fn breadth_first_prefers_the_shorter_route() {
257 let graph = graph_with_edges(
260 6,
261 &[(0, &[1, 2]), (1, &[5]), (2, &[3]), (3, &[4]), (4, &[5])],
262 );
263 let path = graph
264 .shortest_import_path(FileId(0), FileId(5))
265 .expect("target is reachable");
266 assert_eq!(
267 path.iter().map(|hop| hop.to).collect::<Vec<_>>(),
268 vec![FileId(1), FileId(5)]
269 );
270 }
271
272 #[test]
273 fn equal_length_routes_resolve_to_the_smallest_file_id_sequence() {
274 let graph = graph_with_edges(4, &[(0, &[2, 1]), (1, &[3]), (2, &[3])]);
277 let path = graph
278 .shortest_import_path(FileId(0), FileId(3))
279 .expect("target is reachable");
280 assert_eq!(
281 path.iter().map(|hop| hop.to).collect::<Vec<_>>(),
282 vec![FileId(1), FileId(3)]
283 );
284 }
285
286 #[test]
287 fn a_cycle_does_not_stall_the_walk() {
288 let graph = graph_with_edges(4, &[(0, &[1]), (1, &[2]), (2, &[1, 3])]);
289 let path = graph
290 .shortest_import_path(FileId(0), FileId(3))
291 .expect("target is reachable behind a cycle");
292 assert_eq!(path.len(), 3);
293 }
294
295 #[test]
296 fn out_of_range_ids_reach_nothing() {
297 let graph = graph_with_edges(2, &[(0, &[1])]);
298 assert_eq!(graph.shortest_import_path(FileId(0), FileId(9)), None);
299 assert_eq!(graph.shortest_import_path(FileId(9), FileId(1)), None);
300 }
301
302 #[test]
303 fn a_type_only_hop_is_reported_not_skipped() {
304 let graph = graph_with_typed_edges(2, &[(0, &[1])], &[(0, 1)]);
305 let path = graph
306 .shortest_import_path(FileId(0), FileId(1))
307 .expect("a type-only import is still a route");
308 assert_eq!(path.len(), 1);
309 assert!(path[0].all_type_only);
310 }
311}