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
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
169    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
170    use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
171    use std::path::PathBuf;
172
173    // Helper to build a simple module graph
174    fn build_simple_graph() -> ModuleGraph {
175        // Two files: entry.ts imports foo from utils.ts
176        let files = vec![
177            DiscoveredFile {
178                id: FileId(0),
179                path: PathBuf::from("/project/src/entry.ts"),
180                size_bytes: 100,
181            },
182            DiscoveredFile {
183                id: FileId(1),
184                path: PathBuf::from("/project/src/utils.ts"),
185                size_bytes: 50,
186            },
187        ];
188
189        let entry_points = vec![EntryPoint {
190            path: PathBuf::from("/project/src/entry.ts"),
191            source: EntryPointSource::PackageJsonMain,
192        }];
193
194        let resolved_modules = vec![
195            ResolvedModule {
196                file_id: FileId(0),
197                path: PathBuf::from("/project/src/entry.ts"),
198                exports: vec![],
199                re_exports: vec![],
200                resolved_imports: vec![ResolvedImport {
201                    info: ImportInfo {
202                        source: "./utils".to_string(),
203                        imported_name: ImportedName::Named("foo".to_string()),
204                        local_name: "foo".to_string(),
205                        is_type_only: false,
206                        span: oxc_span::Span::new(0, 10),
207                    },
208                    target: ResolveResult::InternalModule(FileId(1)),
209                }],
210                resolved_dynamic_imports: vec![],
211                resolved_dynamic_patterns: vec![],
212                member_accesses: vec![],
213                whole_object_uses: vec![],
214                has_cjs_exports: false,
215                unused_import_bindings: vec![],
216            },
217            ResolvedModule {
218                file_id: FileId(1),
219                path: PathBuf::from("/project/src/utils.ts"),
220                exports: vec![
221                    fallow_types::extract::ExportInfo {
222                        name: ExportName::Named("foo".to_string()),
223                        local_name: Some("foo".to_string()),
224                        is_type_only: false,
225                        span: oxc_span::Span::new(0, 20),
226                        members: vec![],
227                    },
228                    fallow_types::extract::ExportInfo {
229                        name: ExportName::Named("bar".to_string()),
230                        local_name: Some("bar".to_string()),
231                        is_type_only: false,
232                        span: oxc_span::Span::new(25, 45),
233                        members: vec![],
234                    },
235                ],
236                re_exports: vec![],
237                resolved_imports: vec![],
238                resolved_dynamic_imports: vec![],
239                resolved_dynamic_patterns: vec![],
240                member_accesses: vec![],
241                whole_object_uses: vec![],
242                has_cjs_exports: false,
243                unused_import_bindings: vec![],
244            },
245        ];
246
247        ModuleGraph::build(&resolved_modules, &entry_points, &files)
248    }
249
250    #[test]
251    fn graph_module_count() {
252        let graph = build_simple_graph();
253        assert_eq!(graph.module_count(), 2);
254    }
255
256    #[test]
257    fn graph_edge_count() {
258        let graph = build_simple_graph();
259        assert_eq!(graph.edge_count(), 1);
260    }
261
262    #[test]
263    fn graph_entry_point_is_reachable() {
264        let graph = build_simple_graph();
265        assert!(graph.modules[0].is_entry_point);
266        assert!(graph.modules[0].is_reachable);
267    }
268
269    #[test]
270    fn graph_imported_module_is_reachable() {
271        let graph = build_simple_graph();
272        assert!(!graph.modules[1].is_entry_point);
273        assert!(graph.modules[1].is_reachable);
274    }
275
276    #[test]
277    fn graph_export_has_reference() {
278        let graph = build_simple_graph();
279        let utils = &graph.modules[1];
280        let foo_export = utils
281            .exports
282            .iter()
283            .find(|e| e.name.to_string() == "foo")
284            .unwrap();
285        assert!(
286            !foo_export.references.is_empty(),
287            "foo should have references"
288        );
289    }
290
291    #[test]
292    fn graph_unused_export_no_reference() {
293        let graph = build_simple_graph();
294        let utils = &graph.modules[1];
295        let bar_export = utils
296            .exports
297            .iter()
298            .find(|e| e.name.to_string() == "bar")
299            .unwrap();
300        assert!(
301            bar_export.references.is_empty(),
302            "bar should have no references"
303        );
304    }
305
306    #[test]
307    fn graph_no_namespace_import() {
308        let graph = build_simple_graph();
309        assert!(!graph.has_namespace_import(FileId(0)));
310        assert!(!graph.has_namespace_import(FileId(1)));
311    }
312
313    #[test]
314    fn graph_has_namespace_import() {
315        let files = vec![
316            DiscoveredFile {
317                id: FileId(0),
318                path: PathBuf::from("/project/entry.ts"),
319                size_bytes: 100,
320            },
321            DiscoveredFile {
322                id: FileId(1),
323                path: PathBuf::from("/project/utils.ts"),
324                size_bytes: 50,
325            },
326        ];
327
328        let entry_points = vec![EntryPoint {
329            path: PathBuf::from("/project/entry.ts"),
330            source: EntryPointSource::PackageJsonMain,
331        }];
332
333        let resolved_modules = vec![
334            ResolvedModule {
335                file_id: FileId(0),
336                path: PathBuf::from("/project/entry.ts"),
337                exports: vec![],
338                re_exports: vec![],
339                resolved_imports: vec![ResolvedImport {
340                    info: ImportInfo {
341                        source: "./utils".to_string(),
342                        imported_name: ImportedName::Namespace,
343                        local_name: "utils".to_string(),
344                        is_type_only: false,
345                        span: oxc_span::Span::new(0, 10),
346                    },
347                    target: ResolveResult::InternalModule(FileId(1)),
348                }],
349                resolved_dynamic_imports: vec![],
350                resolved_dynamic_patterns: vec![],
351                member_accesses: vec![],
352                whole_object_uses: vec![],
353                has_cjs_exports: false,
354                unused_import_bindings: vec![],
355            },
356            ResolvedModule {
357                file_id: FileId(1),
358                path: PathBuf::from("/project/utils.ts"),
359                exports: vec![fallow_types::extract::ExportInfo {
360                    name: ExportName::Named("foo".to_string()),
361                    local_name: Some("foo".to_string()),
362                    is_type_only: false,
363                    span: oxc_span::Span::new(0, 20),
364                    members: vec![],
365                }],
366                re_exports: vec![],
367                resolved_imports: vec![],
368                resolved_dynamic_imports: vec![],
369                resolved_dynamic_patterns: vec![],
370                member_accesses: vec![],
371                whole_object_uses: vec![],
372                has_cjs_exports: false,
373                unused_import_bindings: vec![],
374            },
375        ];
376
377        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
378        assert!(
379            graph.has_namespace_import(FileId(1)),
380            "utils should have namespace import"
381        );
382    }
383
384    #[test]
385    fn graph_has_namespace_import_out_of_bounds() {
386        let graph = build_simple_graph();
387        assert!(!graph.has_namespace_import(FileId(999)));
388    }
389
390    #[test]
391    fn graph_unreachable_module() {
392        // Three files: entry imports utils, orphan is not imported
393        let files = vec![
394            DiscoveredFile {
395                id: FileId(0),
396                path: PathBuf::from("/project/entry.ts"),
397                size_bytes: 100,
398            },
399            DiscoveredFile {
400                id: FileId(1),
401                path: PathBuf::from("/project/utils.ts"),
402                size_bytes: 50,
403            },
404            DiscoveredFile {
405                id: FileId(2),
406                path: PathBuf::from("/project/orphan.ts"),
407                size_bytes: 30,
408            },
409        ];
410
411        let entry_points = vec![EntryPoint {
412            path: PathBuf::from("/project/entry.ts"),
413            source: EntryPointSource::PackageJsonMain,
414        }];
415
416        let resolved_modules = vec![
417            ResolvedModule {
418                file_id: FileId(0),
419                path: PathBuf::from("/project/entry.ts"),
420                exports: vec![],
421                re_exports: vec![],
422                resolved_imports: vec![ResolvedImport {
423                    info: ImportInfo {
424                        source: "./utils".to_string(),
425                        imported_name: ImportedName::Named("foo".to_string()),
426                        local_name: "foo".to_string(),
427                        is_type_only: false,
428                        span: oxc_span::Span::new(0, 10),
429                    },
430                    target: ResolveResult::InternalModule(FileId(1)),
431                }],
432                resolved_dynamic_imports: vec![],
433                resolved_dynamic_patterns: vec![],
434                member_accesses: vec![],
435                whole_object_uses: vec![],
436                has_cjs_exports: false,
437                unused_import_bindings: vec![],
438            },
439            ResolvedModule {
440                file_id: FileId(1),
441                path: PathBuf::from("/project/utils.ts"),
442                exports: vec![fallow_types::extract::ExportInfo {
443                    name: ExportName::Named("foo".to_string()),
444                    local_name: Some("foo".to_string()),
445                    is_type_only: false,
446                    span: oxc_span::Span::new(0, 20),
447                    members: vec![],
448                }],
449                re_exports: vec![],
450                resolved_imports: vec![],
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(2),
460                path: PathBuf::from("/project/orphan.ts"),
461                exports: vec![fallow_types::extract::ExportInfo {
462                    name: ExportName::Named("orphan".to_string()),
463                    local_name: Some("orphan".to_string()),
464                    is_type_only: false,
465                    span: oxc_span::Span::new(0, 20),
466                    members: vec![],
467                }],
468                re_exports: vec![],
469                resolved_imports: vec![],
470                resolved_dynamic_imports: vec![],
471                resolved_dynamic_patterns: vec![],
472                member_accesses: vec![],
473                whole_object_uses: vec![],
474                has_cjs_exports: false,
475                unused_import_bindings: vec![],
476            },
477        ];
478
479        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
480
481        assert!(graph.modules[0].is_reachable, "entry should be reachable");
482        assert!(graph.modules[1].is_reachable, "utils should be reachable");
483        assert!(
484            !graph.modules[2].is_reachable,
485            "orphan should NOT be reachable"
486        );
487    }
488
489    #[test]
490    fn graph_package_usage_tracked() {
491        let files = vec![DiscoveredFile {
492            id: FileId(0),
493            path: PathBuf::from("/project/entry.ts"),
494            size_bytes: 100,
495        }];
496
497        let entry_points = vec![EntryPoint {
498            path: PathBuf::from("/project/entry.ts"),
499            source: EntryPointSource::PackageJsonMain,
500        }];
501
502        let resolved_modules = vec![ResolvedModule {
503            file_id: FileId(0),
504            path: PathBuf::from("/project/entry.ts"),
505            exports: vec![],
506            re_exports: vec![],
507            resolved_imports: vec![
508                ResolvedImport {
509                    info: ImportInfo {
510                        source: "react".to_string(),
511                        imported_name: ImportedName::Default,
512                        local_name: "React".to_string(),
513                        is_type_only: false,
514                        span: oxc_span::Span::new(0, 10),
515                    },
516                    target: ResolveResult::NpmPackage("react".to_string()),
517                },
518                ResolvedImport {
519                    info: ImportInfo {
520                        source: "lodash".to_string(),
521                        imported_name: ImportedName::Named("merge".to_string()),
522                        local_name: "merge".to_string(),
523                        is_type_only: false,
524                        span: oxc_span::Span::new(15, 30),
525                    },
526                    target: ResolveResult::NpmPackage("lodash".to_string()),
527                },
528            ],
529            resolved_dynamic_imports: vec![],
530            resolved_dynamic_patterns: vec![],
531            member_accesses: vec![],
532            whole_object_uses: vec![],
533            has_cjs_exports: false,
534            unused_import_bindings: vec![],
535        }];
536
537        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
538        assert!(graph.package_usage.contains_key("react"));
539        assert!(graph.package_usage.contains_key("lodash"));
540        assert!(!graph.package_usage.contains_key("express"));
541    }
542
543    #[test]
544    fn graph_empty() {
545        let graph = ModuleGraph::build(&[], &[], &[]);
546        assert_eq!(graph.module_count(), 0);
547        assert_eq!(graph.edge_count(), 0);
548    }
549
550    #[test]
551    fn graph_cjs_exports_tracked() {
552        let files = vec![DiscoveredFile {
553            id: FileId(0),
554            path: PathBuf::from("/project/entry.ts"),
555            size_bytes: 100,
556        }];
557
558        let entry_points = vec![EntryPoint {
559            path: PathBuf::from("/project/entry.ts"),
560            source: EntryPointSource::PackageJsonMain,
561        }];
562
563        let resolved_modules = vec![ResolvedModule {
564            file_id: FileId(0),
565            path: PathBuf::from("/project/entry.ts"),
566            exports: vec![],
567            re_exports: vec![],
568            resolved_imports: vec![],
569            resolved_dynamic_imports: vec![],
570            resolved_dynamic_patterns: vec![],
571            member_accesses: vec![],
572            whole_object_uses: vec![],
573            has_cjs_exports: true,
574            unused_import_bindings: vec![],
575        }];
576
577        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
578        assert!(graph.modules[0].has_cjs_exports);
579    }
580}