Skip to main content

fallow_graph/graph/
shortest_import_path.rs

1//! Shortest import path between two modules.
2//!
3//! `impact_closure` answers "what does a change reach"; this answers "HOW does
4//! one module reach another". It is a plain FIFO breadth-first search over the
5//! forward import edges, so the first path found is a shortest one.
6//!
7//! Determinism is a contract, not a coincidence: the JSON must stay
8//! byte-identical across runs and platforms. Successors are expanded in
9//! ascending `FileId` order and a node keeps the predecessor that discovered it
10//! first, which makes the reported route the lexicographically smallest
11//! `FileId` sequence among all shortest routes. The proof is inductive: the
12//! level-0 frontier is the single source, and a level is enqueued while its
13//! predecessors are dequeued in that same order, so within every level the
14//! queue order equals the lexicographic order of the routes reaching it.
15//!
16//! Type-only hops are REPORTED, never skipped. An `import type` chain is a real
17//! compile-time coupling, and silently dropping it would answer "unreachable"
18//! for a route a reader can see in the source.
19
20use std::collections::VecDeque;
21
22use fallow_types::discover::FileId;
23use fixedbitset::FixedBitSet;
24use rustc_hash::FxHashMap;
25
26use super::ModuleGraph;
27
28/// One import edge on a shortest import path.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ImportPathHop {
31    /// The importing module.
32    pub from: FileId,
33    /// The imported module.
34    pub to: FileId,
35    /// Whether every symbol on this edge is type-only, so the hop exists at
36    /// compile time only. Reported rather than skipped.
37    pub all_type_only: bool,
38    /// Byte offset in `from` of the imported binding that creates this edge,
39    /// as [`ModuleGraph::outgoing_edge_summaries`] reports it: the first
40    /// value-carrying symbol, or the first symbol when every symbol is
41    /// type-only. The caller owns the source text and resolves it to a line.
42    pub import_span_start: Option<u32>,
43}
44
45impl ModuleGraph {
46    /// The shortest import path from `from` to `to`, following import edges in
47    /// the import direction.
48    ///
49    /// Returns `None` when `to` is not reachable from `from`, and
50    /// `Some(empty)` when `from` and `to` are the same module: zero hops is a
51    /// real answer, distinct from no answer at all. Out-of-range file ids have
52    /// no outgoing edges and therefore reach nothing.
53    #[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    /// Outgoing edges of `file_id` as hops, in ascending target order and with
87    /// one hop per target. When a target is reachable over both a value edge
88    /// and a type-only edge, the value edge wins: it is the hop a reader can
89    /// follow at runtime.
90    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
107/// Walk the predecessor chain back from `to` and return it in import order.
108fn 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    /// Build a graph from `(source, targets)` pairs. Every edge carries one
169    /// value symbol whose import span starts at `source * 100 + target`, so a
170    /// hop's reported span identifies the edge it came from.
171    fn graph_with_edges(module_count: u32, edges: &[(u32, &[u32])]) -> ModuleGraph {
172        graph_with_typed_edges(module_count, edges, &[])
173    }
174
175    /// Same, with `type_only_edges` naming the `(source, target)` pairs whose
176    /// import is spelled `import type`.
177    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        // Edges are directed: the importer is not reachable from the imported.
237        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        // Two routes to 5: two hops through 1, four hops through 2. A LIFO walk
256        // drains the deeper branch first and reports the four-hop detour.
257        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        // Two two-hop routes to 3: through 2 and through 1. The smaller
273        // intermediate wins regardless of the order the edges were declared in.
274        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}