Skip to main content

fallow_graph/graph/
mod.rs

1//! Module dependency graph with re-export chain propagation and reachability analysis.
2//!
3//! The graph is built from resolved modules and entry points, then used to determine
4//! which files are reachable and which exports are referenced.
5
6mod build;
7mod cycles;
8mod re_exports;
9mod reachability;
10pub mod types;
11
12use std::path::PathBuf;
13
14use fixedbitset::FixedBitSet;
15use rustc_hash::{FxHashMap, FxHashSet};
16
17use crate::resolve::ResolvedModule;
18use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
19use fallow_types::extract::ImportedName;
20
21// Re-export all public types so downstream sees the same API as before.
22pub use types::{ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, SymbolReference};
23
24/// The core module dependency graph.
25#[derive(Debug)]
26pub struct ModuleGraph {
27    /// All modules indexed by `FileId`.
28    pub modules: Vec<ModuleNode>,
29    /// Flat edge storage for cache-friendly iteration.
30    edges: Vec<Edge>,
31    /// Maps npm package names to the set of `FileId`s that import them.
32    pub package_usage: FxHashMap<String, Vec<FileId>>,
33    /// Maps npm package names to the set of `FileId`s that import them with type-only imports.
34    /// A package appearing here but not in `package_usage` (or only in both) indicates
35    /// it's only used for types and could be a devDependency.
36    pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
37    /// All entry point `FileId`s.
38    pub entry_points: FxHashSet<FileId>,
39    /// Reverse index: for each `FileId`, which files import it.
40    pub reverse_deps: Vec<Vec<FileId>>,
41    /// Precomputed: which modules have namespace imports (import * as ns).
42    namespace_imported: FixedBitSet,
43}
44
45/// An edge in the module graph.
46#[derive(Debug)]
47pub(super) struct Edge {
48    pub(super) source: FileId,
49    pub(super) target: FileId,
50    pub(super) symbols: Vec<ImportedSymbol>,
51}
52
53/// A symbol imported across an edge.
54#[derive(Debug)]
55pub(super) struct ImportedSymbol {
56    pub(super) imported_name: ImportedName,
57    pub(super) local_name: String,
58    /// Byte span of the import statement in the source file.
59    pub(super) import_span: oxc_span::Span,
60}
61
62// Size assertions to prevent memory regressions in hot-path graph types.
63// `Edge` is stored in a flat contiguous Vec for cache-friendly traversal.
64// `ImportedSymbol` is stored in a Vec per Edge.
65#[cfg(target_pointer_width = "64")]
66const _: () = assert!(std::mem::size_of::<Edge>() == 32);
67#[cfg(target_pointer_width = "64")]
68const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 56);
69
70impl ModuleGraph {
71    /// Build the module graph from resolved modules and entry points.
72    pub fn build(
73        resolved_modules: &[ResolvedModule],
74        entry_points: &[EntryPoint],
75        files: &[DiscoveredFile],
76    ) -> Self {
77        let _span = tracing::info_span!("build_graph").entered();
78
79        let module_count = files.len();
80
81        // Compute the total capacity needed, accounting for workspace FileIds
82        // that may exceed files.len() if IDs are assigned beyond the file count.
83        let max_file_id = files
84            .iter()
85            .map(|f| f.id.0 as usize)
86            .max()
87            .map_or(0, |m| m + 1);
88        let total_capacity = max_file_id.max(module_count);
89
90        // Build path -> FileId index
91        let path_to_id: FxHashMap<PathBuf, FileId> =
92            files.iter().map(|f| (f.path.clone(), f.id)).collect();
93
94        // Build FileId -> ResolvedModule index
95        let module_by_id: FxHashMap<FileId, &ResolvedModule> =
96            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
97
98        // Build entry point set — use path_to_id map instead of O(n) scan per entry
99        let entry_point_ids: FxHashSet<FileId> = entry_points
100            .iter()
101            .filter_map(|ep| {
102                // Try direct lookup first (fast path)
103                path_to_id.get(&ep.path).copied().or_else(|| {
104                    // Fallback: canonicalize entry point and do a direct FxHashMap lookup
105                    ep.path
106                        .canonicalize()
107                        .ok()
108                        .and_then(|c| path_to_id.get(&c).copied())
109                })
110            })
111            .collect();
112
113        // Phase 1: Build flat edge storage, module nodes, and package usage from resolved modules
114        let mut graph = Self::populate_edges(
115            files,
116            &module_by_id,
117            &entry_point_ids,
118            module_count,
119            total_capacity,
120        );
121
122        // Phase 2: Record which files reference which exports (namespace + CSS module narrowing)
123        graph.populate_references(&module_by_id, &entry_point_ids);
124
125        // Phase 3: BFS from entry points to mark reachable modules
126        graph.mark_reachable(total_capacity);
127
128        // Phase 4: Propagate references through re-export chains
129        graph.resolve_re_export_chains();
130
131        graph
132    }
133
134    /// Total number of modules.
135    pub const fn module_count(&self) -> usize {
136        self.modules.len()
137    }
138
139    /// Total number of edges.
140    pub const fn edge_count(&self) -> usize {
141        self.edges.len()
142    }
143
144    /// Check if any importer uses `import * as ns` for this module.
145    /// Uses precomputed bitset — O(1) lookup.
146    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
147        let idx = file_id.0 as usize;
148        if idx >= self.namespace_imported.len() {
149            return false;
150        }
151        self.namespace_imported.contains(idx)
152    }
153
154    /// Get the target `FileId`s of all outgoing edges for a module.
155    pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
156        let idx = file_id.0 as usize;
157        if idx >= self.modules.len() {
158            return Vec::new();
159        }
160        let range = &self.modules[idx].edge_range;
161        self.edges[range.clone()].iter().map(|e| e.target).collect()
162    }
163
164    /// Find the byte offset of the first import statement from `source` to `target`.
165    /// Returns `None` if no edge exists or the edge has no symbols.
166    pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
167        let idx = source.0 as usize;
168        if idx >= self.modules.len() {
169            return None;
170        }
171        let range = &self.modules[idx].edge_range;
172        for edge in &self.edges[range.clone()] {
173            if edge.target == target {
174                return edge.symbols.first().map(|s| s.import_span.start);
175            }
176        }
177        None
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
185    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
186    use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
187    use std::path::PathBuf;
188
189    // Helper to build a simple module graph
190    fn build_simple_graph() -> ModuleGraph {
191        // Two files: entry.ts imports foo from utils.ts
192        let files = vec![
193            DiscoveredFile {
194                id: FileId(0),
195                path: PathBuf::from("/project/src/entry.ts"),
196                size_bytes: 100,
197            },
198            DiscoveredFile {
199                id: FileId(1),
200                path: PathBuf::from("/project/src/utils.ts"),
201                size_bytes: 50,
202            },
203        ];
204
205        let entry_points = vec![EntryPoint {
206            path: PathBuf::from("/project/src/entry.ts"),
207            source: EntryPointSource::PackageJsonMain,
208        }];
209
210        let resolved_modules = vec![
211            ResolvedModule {
212                file_id: FileId(0),
213                path: PathBuf::from("/project/src/entry.ts"),
214                exports: vec![],
215                re_exports: vec![],
216                resolved_imports: vec![ResolvedImport {
217                    info: ImportInfo {
218                        source: "./utils".to_string(),
219                        imported_name: ImportedName::Named("foo".to_string()),
220                        local_name: "foo".to_string(),
221                        is_type_only: false,
222                        span: oxc_span::Span::new(0, 10),
223                    },
224                    target: ResolveResult::InternalModule(FileId(1)),
225                }],
226                resolved_dynamic_imports: vec![],
227                resolved_dynamic_patterns: vec![],
228                member_accesses: vec![],
229                whole_object_uses: vec![],
230                has_cjs_exports: false,
231                unused_import_bindings: vec![],
232            },
233            ResolvedModule {
234                file_id: FileId(1),
235                path: PathBuf::from("/project/src/utils.ts"),
236                exports: vec![
237                    fallow_types::extract::ExportInfo {
238                        name: ExportName::Named("foo".to_string()),
239                        local_name: Some("foo".to_string()),
240                        is_type_only: false,
241                        is_public: false,
242                        span: oxc_span::Span::new(0, 20),
243                        members: vec![],
244                    },
245                    fallow_types::extract::ExportInfo {
246                        name: ExportName::Named("bar".to_string()),
247                        local_name: Some("bar".to_string()),
248                        is_type_only: false,
249                        is_public: false,
250                        span: oxc_span::Span::new(25, 45),
251                        members: vec![],
252                    },
253                ],
254                re_exports: vec![],
255                resolved_imports: vec![],
256                resolved_dynamic_imports: vec![],
257                resolved_dynamic_patterns: vec![],
258                member_accesses: vec![],
259                whole_object_uses: vec![],
260                has_cjs_exports: false,
261                unused_import_bindings: vec![],
262            },
263        ];
264
265        ModuleGraph::build(&resolved_modules, &entry_points, &files)
266    }
267
268    #[test]
269    fn graph_module_count() {
270        let graph = build_simple_graph();
271        assert_eq!(graph.module_count(), 2);
272    }
273
274    #[test]
275    fn graph_edge_count() {
276        let graph = build_simple_graph();
277        assert_eq!(graph.edge_count(), 1);
278    }
279
280    #[test]
281    fn graph_entry_point_is_reachable() {
282        let graph = build_simple_graph();
283        assert!(graph.modules[0].is_entry_point);
284        assert!(graph.modules[0].is_reachable);
285    }
286
287    #[test]
288    fn graph_imported_module_is_reachable() {
289        let graph = build_simple_graph();
290        assert!(!graph.modules[1].is_entry_point);
291        assert!(graph.modules[1].is_reachable);
292    }
293
294    #[test]
295    fn graph_export_has_reference() {
296        let graph = build_simple_graph();
297        let utils = &graph.modules[1];
298        let foo_export = utils
299            .exports
300            .iter()
301            .find(|e| e.name.to_string() == "foo")
302            .unwrap();
303        assert!(
304            !foo_export.references.is_empty(),
305            "foo should have references"
306        );
307    }
308
309    #[test]
310    fn graph_unused_export_no_reference() {
311        let graph = build_simple_graph();
312        let utils = &graph.modules[1];
313        let bar_export = utils
314            .exports
315            .iter()
316            .find(|e| e.name.to_string() == "bar")
317            .unwrap();
318        assert!(
319            bar_export.references.is_empty(),
320            "bar should have no references"
321        );
322    }
323
324    #[test]
325    fn graph_no_namespace_import() {
326        let graph = build_simple_graph();
327        assert!(!graph.has_namespace_import(FileId(0)));
328        assert!(!graph.has_namespace_import(FileId(1)));
329    }
330
331    #[test]
332    fn graph_has_namespace_import() {
333        let files = vec![
334            DiscoveredFile {
335                id: FileId(0),
336                path: PathBuf::from("/project/entry.ts"),
337                size_bytes: 100,
338            },
339            DiscoveredFile {
340                id: FileId(1),
341                path: PathBuf::from("/project/utils.ts"),
342                size_bytes: 50,
343            },
344        ];
345
346        let entry_points = vec![EntryPoint {
347            path: PathBuf::from("/project/entry.ts"),
348            source: EntryPointSource::PackageJsonMain,
349        }];
350
351        let resolved_modules = vec![
352            ResolvedModule {
353                file_id: FileId(0),
354                path: PathBuf::from("/project/entry.ts"),
355                exports: vec![],
356                re_exports: vec![],
357                resolved_imports: vec![ResolvedImport {
358                    info: ImportInfo {
359                        source: "./utils".to_string(),
360                        imported_name: ImportedName::Namespace,
361                        local_name: "utils".to_string(),
362                        is_type_only: false,
363                        span: oxc_span::Span::new(0, 10),
364                    },
365                    target: ResolveResult::InternalModule(FileId(1)),
366                }],
367                resolved_dynamic_imports: vec![],
368                resolved_dynamic_patterns: vec![],
369                member_accesses: vec![],
370                whole_object_uses: vec![],
371                has_cjs_exports: false,
372                unused_import_bindings: vec![],
373            },
374            ResolvedModule {
375                file_id: FileId(1),
376                path: PathBuf::from("/project/utils.ts"),
377                exports: vec![fallow_types::extract::ExportInfo {
378                    name: ExportName::Named("foo".to_string()),
379                    local_name: Some("foo".to_string()),
380                    is_type_only: false,
381                    is_public: false,
382                    span: oxc_span::Span::new(0, 20),
383                    members: vec![],
384                }],
385                re_exports: vec![],
386                resolved_imports: vec![],
387                resolved_dynamic_imports: vec![],
388                resolved_dynamic_patterns: vec![],
389                member_accesses: vec![],
390                whole_object_uses: vec![],
391                has_cjs_exports: false,
392                unused_import_bindings: vec![],
393            },
394        ];
395
396        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
397        assert!(
398            graph.has_namespace_import(FileId(1)),
399            "utils should have namespace import"
400        );
401    }
402
403    #[test]
404    fn graph_has_namespace_import_out_of_bounds() {
405        let graph = build_simple_graph();
406        assert!(!graph.has_namespace_import(FileId(999)));
407    }
408
409    #[test]
410    fn graph_unreachable_module() {
411        // Three files: entry imports utils, orphan is not imported
412        let files = vec![
413            DiscoveredFile {
414                id: FileId(0),
415                path: PathBuf::from("/project/entry.ts"),
416                size_bytes: 100,
417            },
418            DiscoveredFile {
419                id: FileId(1),
420                path: PathBuf::from("/project/utils.ts"),
421                size_bytes: 50,
422            },
423            DiscoveredFile {
424                id: FileId(2),
425                path: PathBuf::from("/project/orphan.ts"),
426                size_bytes: 30,
427            },
428        ];
429
430        let entry_points = vec![EntryPoint {
431            path: PathBuf::from("/project/entry.ts"),
432            source: EntryPointSource::PackageJsonMain,
433        }];
434
435        let resolved_modules = vec![
436            ResolvedModule {
437                file_id: FileId(0),
438                path: PathBuf::from("/project/entry.ts"),
439                exports: vec![],
440                re_exports: vec![],
441                resolved_imports: vec![ResolvedImport {
442                    info: ImportInfo {
443                        source: "./utils".to_string(),
444                        imported_name: ImportedName::Named("foo".to_string()),
445                        local_name: "foo".to_string(),
446                        is_type_only: false,
447                        span: oxc_span::Span::new(0, 10),
448                    },
449                    target: ResolveResult::InternalModule(FileId(1)),
450                }],
451                resolved_dynamic_imports: vec![],
452                resolved_dynamic_patterns: vec![],
453                member_accesses: vec![],
454                whole_object_uses: vec![],
455                has_cjs_exports: false,
456                unused_import_bindings: vec![],
457            },
458            ResolvedModule {
459                file_id: FileId(1),
460                path: PathBuf::from("/project/utils.ts"),
461                exports: vec![fallow_types::extract::ExportInfo {
462                    name: ExportName::Named("foo".to_string()),
463                    local_name: Some("foo".to_string()),
464                    is_type_only: false,
465                    is_public: false,
466                    span: oxc_span::Span::new(0, 20),
467                    members: vec![],
468                }],
469                re_exports: vec![],
470                resolved_imports: vec![],
471                resolved_dynamic_imports: vec![],
472                resolved_dynamic_patterns: vec![],
473                member_accesses: vec![],
474                whole_object_uses: vec![],
475                has_cjs_exports: false,
476                unused_import_bindings: vec![],
477            },
478            ResolvedModule {
479                file_id: FileId(2),
480                path: PathBuf::from("/project/orphan.ts"),
481                exports: vec![fallow_types::extract::ExportInfo {
482                    name: ExportName::Named("orphan".to_string()),
483                    local_name: Some("orphan".to_string()),
484                    is_type_only: false,
485                    is_public: false,
486                    span: oxc_span::Span::new(0, 20),
487                    members: vec![],
488                }],
489                re_exports: vec![],
490                resolved_imports: vec![],
491                resolved_dynamic_imports: vec![],
492                resolved_dynamic_patterns: vec![],
493                member_accesses: vec![],
494                whole_object_uses: vec![],
495                has_cjs_exports: false,
496                unused_import_bindings: vec![],
497            },
498        ];
499
500        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
501
502        assert!(graph.modules[0].is_reachable, "entry should be reachable");
503        assert!(graph.modules[1].is_reachable, "utils should be reachable");
504        assert!(
505            !graph.modules[2].is_reachable,
506            "orphan should NOT be reachable"
507        );
508    }
509
510    #[test]
511    fn graph_package_usage_tracked() {
512        let files = vec![DiscoveredFile {
513            id: FileId(0),
514            path: PathBuf::from("/project/entry.ts"),
515            size_bytes: 100,
516        }];
517
518        let entry_points = vec![EntryPoint {
519            path: PathBuf::from("/project/entry.ts"),
520            source: EntryPointSource::PackageJsonMain,
521        }];
522
523        let resolved_modules = vec![ResolvedModule {
524            file_id: FileId(0),
525            path: PathBuf::from("/project/entry.ts"),
526            exports: vec![],
527            re_exports: vec![],
528            resolved_imports: vec![
529                ResolvedImport {
530                    info: ImportInfo {
531                        source: "react".to_string(),
532                        imported_name: ImportedName::Default,
533                        local_name: "React".to_string(),
534                        is_type_only: false,
535                        span: oxc_span::Span::new(0, 10),
536                    },
537                    target: ResolveResult::NpmPackage("react".to_string()),
538                },
539                ResolvedImport {
540                    info: ImportInfo {
541                        source: "lodash".to_string(),
542                        imported_name: ImportedName::Named("merge".to_string()),
543                        local_name: "merge".to_string(),
544                        is_type_only: false,
545                        span: oxc_span::Span::new(15, 30),
546                    },
547                    target: ResolveResult::NpmPackage("lodash".to_string()),
548                },
549            ],
550            resolved_dynamic_imports: vec![],
551            resolved_dynamic_patterns: vec![],
552            member_accesses: vec![],
553            whole_object_uses: vec![],
554            has_cjs_exports: false,
555            unused_import_bindings: vec![],
556        }];
557
558        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
559        assert!(graph.package_usage.contains_key("react"));
560        assert!(graph.package_usage.contains_key("lodash"));
561        assert!(!graph.package_usage.contains_key("express"));
562    }
563
564    #[test]
565    fn graph_empty() {
566        let graph = ModuleGraph::build(&[], &[], &[]);
567        assert_eq!(graph.module_count(), 0);
568        assert_eq!(graph.edge_count(), 0);
569    }
570
571    #[test]
572    fn graph_cjs_exports_tracked() {
573        let files = vec![DiscoveredFile {
574            id: FileId(0),
575            path: PathBuf::from("/project/entry.ts"),
576            size_bytes: 100,
577        }];
578
579        let entry_points = vec![EntryPoint {
580            path: PathBuf::from("/project/entry.ts"),
581            source: EntryPointSource::PackageJsonMain,
582        }];
583
584        let resolved_modules = vec![ResolvedModule {
585            file_id: FileId(0),
586            path: PathBuf::from("/project/entry.ts"),
587            exports: vec![],
588            re_exports: vec![],
589            resolved_imports: vec![],
590            resolved_dynamic_imports: vec![],
591            resolved_dynamic_patterns: vec![],
592            member_accesses: vec![],
593            whole_object_uses: vec![],
594            has_cjs_exports: true,
595            unused_import_bindings: vec![],
596        }];
597
598        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
599        assert!(graph.modules[0].has_cjs_exports);
600    }
601}