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