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 }
166 }
167
168 fn graph_with_edges(module_count: u32, edges: &[(u32, &[u32])]) -> ModuleGraph {
172 graph_with_typed_edges(module_count, edges, &[])
173 }
174
175 fn graph_with_typed_edges(
178 module_count: u32,
179 edges: &[(u32, &[u32])],
180 type_only_edges: &[(u32, u32)],
181 ) -> ModuleGraph {
182 let files: Vec<DiscoveredFile> = (0..module_count)
183 .map(|id| DiscoveredFile {
184 id: FileId(id),
185 path: module_path(id),
186 size_bytes: 10,
187 })
188 .collect();
189 let entry_points = vec![EntryPoint {
190 path: module_path(0),
191 source: EntryPointSource::PackageJsonMain,
192 }];
193 let resolved: Vec<ResolvedModule> = (0..module_count)
194 .map(|id| {
195 let resolved_imports = edges
196 .iter()
197 .find(|(source, _)| *source == id)
198 .map(|(source, targets)| {
199 targets
200 .iter()
201 .map(|&target| {
202 import(
203 target,
204 source * 100 + target,
205 type_only_edges.contains(&(*source, target)),
206 )
207 })
208 .collect()
209 })
210 .unwrap_or_default();
211 ResolvedModule {
212 file_id: FileId(id),
213 path: module_path(id),
214 resolved_imports,
215 exports: vec![value_export()].into(),
216 ..Default::default()
217 }
218 })
219 .collect();
220 ModuleGraph::build(&resolved, &entry_points, &files)
221 }
222
223 #[test]
224 fn same_module_is_zero_hops_not_unreachable() {
225 let graph = graph_with_edges(2, &[(0, &[1])]);
226 assert_eq!(
227 graph.shortest_import_path(FileId(0), FileId(0)),
228 Some(vec![])
229 );
230 }
231
232 #[test]
233 fn unreachable_target_has_no_path() {
234 let graph = graph_with_edges(3, &[(0, &[1])]);
235 assert_eq!(graph.shortest_import_path(FileId(0), FileId(2)), None);
236 assert_eq!(graph.shortest_import_path(FileId(1), FileId(0)), None);
238 }
239
240 #[test]
241 fn direct_import_is_one_hop_with_its_import_span() {
242 let graph = graph_with_edges(2, &[(0, &[1])]);
243 let path = graph
244 .shortest_import_path(FileId(0), FileId(1))
245 .expect("direct import is reachable");
246 assert_eq!(path.len(), 1);
247 assert_eq!(path[0].from, FileId(0));
248 assert_eq!(path[0].to, FileId(1));
249 assert!(!path[0].all_type_only);
250 assert_eq!(path[0].import_span_start, Some(1));
251 }
252
253 #[test]
254 fn breadth_first_prefers_the_shorter_route() {
255 let graph = graph_with_edges(
258 6,
259 &[(0, &[1, 2]), (1, &[5]), (2, &[3]), (3, &[4]), (4, &[5])],
260 );
261 let path = graph
262 .shortest_import_path(FileId(0), FileId(5))
263 .expect("target is reachable");
264 assert_eq!(
265 path.iter().map(|hop| hop.to).collect::<Vec<_>>(),
266 vec![FileId(1), FileId(5)]
267 );
268 }
269
270 #[test]
271 fn equal_length_routes_resolve_to_the_smallest_file_id_sequence() {
272 let graph = graph_with_edges(4, &[(0, &[2, 1]), (1, &[3]), (2, &[3])]);
275 let path = graph
276 .shortest_import_path(FileId(0), FileId(3))
277 .expect("target is reachable");
278 assert_eq!(
279 path.iter().map(|hop| hop.to).collect::<Vec<_>>(),
280 vec![FileId(1), FileId(3)]
281 );
282 }
283
284 #[test]
285 fn a_cycle_does_not_stall_the_walk() {
286 let graph = graph_with_edges(4, &[(0, &[1]), (1, &[2]), (2, &[1, 3])]);
287 let path = graph
288 .shortest_import_path(FileId(0), FileId(3))
289 .expect("target is reachable behind a cycle");
290 assert_eq!(path.len(), 3);
291 }
292
293 #[test]
294 fn out_of_range_ids_reach_nothing() {
295 let graph = graph_with_edges(2, &[(0, &[1])]);
296 assert_eq!(graph.shortest_import_path(FileId(0), FileId(9)), None);
297 assert_eq!(graph.shortest_import_path(FileId(9), FileId(1)), None);
298 }
299
300 #[test]
301 fn a_type_only_hop_is_reported_not_skipped() {
302 let graph = graph_with_typed_edges(2, &[(0, &[1])], &[(0, 1)]);
303 let path = graph
304 .shortest_import_path(FileId(0), FileId(1))
305 .expect("a type-only import is still a route");
306 assert_eq!(path.len(), 1);
307 assert!(path[0].all_type_only);
308 }
309}