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