1mod 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
21pub use types::{ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, SymbolReference};
23
24#[derive(Debug)]
26pub struct ModuleGraph {
27 pub modules: Vec<ModuleNode>,
29 edges: Vec<Edge>,
31 pub package_usage: FxHashMap<String, Vec<FileId>>,
33 pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
37 pub entry_points: FxHashSet<FileId>,
39 pub reverse_deps: Vec<Vec<FileId>>,
41 namespace_imported: FixedBitSet,
43}
44
45#[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#[derive(Debug)]
55pub(super) struct ImportedSymbol {
56 pub(super) imported_name: ImportedName,
57 pub(super) local_name: String,
58 pub(super) import_span: oxc_span::Span,
60}
61
62#[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 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 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 let path_to_id: FxHashMap<PathBuf, FileId> =
92 files.iter().map(|f| (f.path.clone(), f.id)).collect();
93
94 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
96 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
97
98 let entry_point_ids: FxHashSet<FileId> = entry_points
100 .iter()
101 .filter_map(|ep| {
102 path_to_id.get(&ep.path).copied().or_else(|| {
104 ep.path
106 .canonicalize()
107 .ok()
108 .and_then(|c| path_to_id.get(&c).copied())
109 })
110 })
111 .collect();
112
113 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 graph.populate_references(&module_by_id, &entry_point_ids);
124
125 graph.mark_reachable(total_capacity);
127
128 graph.resolve_re_export_chains();
130
131 graph
132 }
133
134 pub const fn module_count(&self) -> usize {
136 self.modules.len()
137 }
138
139 pub const fn edge_count(&self) -> usize {
141 self.edges.len()
142 }
143
144 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 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 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 fn build_simple_graph() -> ModuleGraph {
191 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 source_span: oxc_span::Span::default(),
224 },
225 target: ResolveResult::InternalModule(FileId(1)),
226 }],
227 resolved_dynamic_imports: vec![],
228 resolved_dynamic_patterns: vec![],
229 member_accesses: vec![],
230 whole_object_uses: vec![],
231 has_cjs_exports: false,
232 unused_import_bindings: vec![],
233 },
234 ResolvedModule {
235 file_id: FileId(1),
236 path: PathBuf::from("/project/src/utils.ts"),
237 exports: vec![
238 fallow_types::extract::ExportInfo {
239 name: ExportName::Named("foo".to_string()),
240 local_name: Some("foo".to_string()),
241 is_type_only: false,
242 is_public: false,
243 span: oxc_span::Span::new(0, 20),
244 members: vec![],
245 },
246 fallow_types::extract::ExportInfo {
247 name: ExportName::Named("bar".to_string()),
248 local_name: Some("bar".to_string()),
249 is_type_only: false,
250 is_public: false,
251 span: oxc_span::Span::new(25, 45),
252 members: vec![],
253 },
254 ],
255 re_exports: vec![],
256 resolved_imports: vec![],
257 resolved_dynamic_imports: vec![],
258 resolved_dynamic_patterns: vec![],
259 member_accesses: vec![],
260 whole_object_uses: vec![],
261 has_cjs_exports: false,
262 unused_import_bindings: vec![],
263 },
264 ];
265
266 ModuleGraph::build(&resolved_modules, &entry_points, &files)
267 }
268
269 #[test]
270 fn graph_module_count() {
271 let graph = build_simple_graph();
272 assert_eq!(graph.module_count(), 2);
273 }
274
275 #[test]
276 fn graph_edge_count() {
277 let graph = build_simple_graph();
278 assert_eq!(graph.edge_count(), 1);
279 }
280
281 #[test]
282 fn graph_entry_point_is_reachable() {
283 let graph = build_simple_graph();
284 assert!(graph.modules[0].is_entry_point);
285 assert!(graph.modules[0].is_reachable);
286 }
287
288 #[test]
289 fn graph_imported_module_is_reachable() {
290 let graph = build_simple_graph();
291 assert!(!graph.modules[1].is_entry_point);
292 assert!(graph.modules[1].is_reachable);
293 }
294
295 #[test]
296 fn graph_export_has_reference() {
297 let graph = build_simple_graph();
298 let utils = &graph.modules[1];
299 let foo_export = utils
300 .exports
301 .iter()
302 .find(|e| e.name.to_string() == "foo")
303 .unwrap();
304 assert!(
305 !foo_export.references.is_empty(),
306 "foo should have references"
307 );
308 }
309
310 #[test]
311 fn graph_unused_export_no_reference() {
312 let graph = build_simple_graph();
313 let utils = &graph.modules[1];
314 let bar_export = utils
315 .exports
316 .iter()
317 .find(|e| e.name.to_string() == "bar")
318 .unwrap();
319 assert!(
320 bar_export.references.is_empty(),
321 "bar should have no references"
322 );
323 }
324
325 #[test]
326 fn graph_no_namespace_import() {
327 let graph = build_simple_graph();
328 assert!(!graph.has_namespace_import(FileId(0)));
329 assert!(!graph.has_namespace_import(FileId(1)));
330 }
331
332 #[test]
333 fn graph_has_namespace_import() {
334 let files = vec![
335 DiscoveredFile {
336 id: FileId(0),
337 path: PathBuf::from("/project/entry.ts"),
338 size_bytes: 100,
339 },
340 DiscoveredFile {
341 id: FileId(1),
342 path: PathBuf::from("/project/utils.ts"),
343 size_bytes: 50,
344 },
345 ];
346
347 let entry_points = vec![EntryPoint {
348 path: PathBuf::from("/project/entry.ts"),
349 source: EntryPointSource::PackageJsonMain,
350 }];
351
352 let resolved_modules = vec![
353 ResolvedModule {
354 file_id: FileId(0),
355 path: PathBuf::from("/project/entry.ts"),
356 exports: vec![],
357 re_exports: vec![],
358 resolved_imports: vec![ResolvedImport {
359 info: ImportInfo {
360 source: "./utils".to_string(),
361 imported_name: ImportedName::Namespace,
362 local_name: "utils".to_string(),
363 is_type_only: false,
364 span: oxc_span::Span::new(0, 10),
365 source_span: oxc_span::Span::default(),
366 },
367 target: ResolveResult::InternalModule(FileId(1)),
368 }],
369 resolved_dynamic_imports: vec![],
370 resolved_dynamic_patterns: vec![],
371 member_accesses: vec![],
372 whole_object_uses: vec![],
373 has_cjs_exports: false,
374 unused_import_bindings: vec![],
375 },
376 ResolvedModule {
377 file_id: FileId(1),
378 path: PathBuf::from("/project/utils.ts"),
379 exports: vec![fallow_types::extract::ExportInfo {
380 name: ExportName::Named("foo".to_string()),
381 local_name: Some("foo".to_string()),
382 is_type_only: false,
383 is_public: false,
384 span: oxc_span::Span::new(0, 20),
385 members: vec![],
386 }],
387 re_exports: vec![],
388 resolved_imports: vec![],
389 resolved_dynamic_imports: vec![],
390 resolved_dynamic_patterns: vec![],
391 member_accesses: vec![],
392 whole_object_uses: vec![],
393 has_cjs_exports: false,
394 unused_import_bindings: vec![],
395 },
396 ];
397
398 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
399 assert!(
400 graph.has_namespace_import(FileId(1)),
401 "utils should have namespace import"
402 );
403 }
404
405 #[test]
406 fn graph_has_namespace_import_out_of_bounds() {
407 let graph = build_simple_graph();
408 assert!(!graph.has_namespace_import(FileId(999)));
409 }
410
411 #[test]
412 fn graph_unreachable_module() {
413 let files = vec![
415 DiscoveredFile {
416 id: FileId(0),
417 path: PathBuf::from("/project/entry.ts"),
418 size_bytes: 100,
419 },
420 DiscoveredFile {
421 id: FileId(1),
422 path: PathBuf::from("/project/utils.ts"),
423 size_bytes: 50,
424 },
425 DiscoveredFile {
426 id: FileId(2),
427 path: PathBuf::from("/project/orphan.ts"),
428 size_bytes: 30,
429 },
430 ];
431
432 let entry_points = vec![EntryPoint {
433 path: PathBuf::from("/project/entry.ts"),
434 source: EntryPointSource::PackageJsonMain,
435 }];
436
437 let resolved_modules = vec![
438 ResolvedModule {
439 file_id: FileId(0),
440 path: PathBuf::from("/project/entry.ts"),
441 exports: vec![],
442 re_exports: vec![],
443 resolved_imports: vec![ResolvedImport {
444 info: ImportInfo {
445 source: "./utils".to_string(),
446 imported_name: ImportedName::Named("foo".to_string()),
447 local_name: "foo".to_string(),
448 is_type_only: false,
449 span: oxc_span::Span::new(0, 10),
450 source_span: oxc_span::Span::default(),
451 },
452 target: ResolveResult::InternalModule(FileId(1)),
453 }],
454 resolved_dynamic_imports: vec![],
455 resolved_dynamic_patterns: vec![],
456 member_accesses: vec![],
457 whole_object_uses: vec![],
458 has_cjs_exports: false,
459 unused_import_bindings: vec![],
460 },
461 ResolvedModule {
462 file_id: FileId(1),
463 path: PathBuf::from("/project/utils.ts"),
464 exports: vec![fallow_types::extract::ExportInfo {
465 name: ExportName::Named("foo".to_string()),
466 local_name: Some("foo".to_string()),
467 is_type_only: false,
468 is_public: false,
469 span: oxc_span::Span::new(0, 20),
470 members: vec![],
471 }],
472 re_exports: vec![],
473 resolved_imports: vec![],
474 resolved_dynamic_imports: vec![],
475 resolved_dynamic_patterns: vec![],
476 member_accesses: vec![],
477 whole_object_uses: vec![],
478 has_cjs_exports: false,
479 unused_import_bindings: vec![],
480 },
481 ResolvedModule {
482 file_id: FileId(2),
483 path: PathBuf::from("/project/orphan.ts"),
484 exports: vec![fallow_types::extract::ExportInfo {
485 name: ExportName::Named("orphan".to_string()),
486 local_name: Some("orphan".to_string()),
487 is_type_only: false,
488 is_public: false,
489 span: oxc_span::Span::new(0, 20),
490 members: vec![],
491 }],
492 re_exports: vec![],
493 resolved_imports: vec![],
494 resolved_dynamic_imports: vec![],
495 resolved_dynamic_patterns: vec![],
496 member_accesses: vec![],
497 whole_object_uses: vec![],
498 has_cjs_exports: false,
499 unused_import_bindings: vec![],
500 },
501 ];
502
503 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
504
505 assert!(graph.modules[0].is_reachable, "entry should be reachable");
506 assert!(graph.modules[1].is_reachable, "utils should be reachable");
507 assert!(
508 !graph.modules[2].is_reachable,
509 "orphan should NOT be reachable"
510 );
511 }
512
513 #[test]
514 fn graph_package_usage_tracked() {
515 let files = vec![DiscoveredFile {
516 id: FileId(0),
517 path: PathBuf::from("/project/entry.ts"),
518 size_bytes: 100,
519 }];
520
521 let entry_points = vec![EntryPoint {
522 path: PathBuf::from("/project/entry.ts"),
523 source: EntryPointSource::PackageJsonMain,
524 }];
525
526 let resolved_modules = vec![ResolvedModule {
527 file_id: FileId(0),
528 path: PathBuf::from("/project/entry.ts"),
529 exports: vec![],
530 re_exports: vec![],
531 resolved_imports: vec![
532 ResolvedImport {
533 info: ImportInfo {
534 source: "react".to_string(),
535 imported_name: ImportedName::Default,
536 local_name: "React".to_string(),
537 is_type_only: false,
538 span: oxc_span::Span::new(0, 10),
539 source_span: oxc_span::Span::default(),
540 },
541 target: ResolveResult::NpmPackage("react".to_string()),
542 },
543 ResolvedImport {
544 info: ImportInfo {
545 source: "lodash".to_string(),
546 imported_name: ImportedName::Named("merge".to_string()),
547 local_name: "merge".to_string(),
548 is_type_only: false,
549 span: oxc_span::Span::new(15, 30),
550 source_span: oxc_span::Span::default(),
551 },
552 target: ResolveResult::NpmPackage("lodash".to_string()),
553 },
554 ],
555 resolved_dynamic_imports: vec![],
556 resolved_dynamic_patterns: vec![],
557 member_accesses: vec![],
558 whole_object_uses: vec![],
559 has_cjs_exports: false,
560 unused_import_bindings: vec![],
561 }];
562
563 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
564 assert!(graph.package_usage.contains_key("react"));
565 assert!(graph.package_usage.contains_key("lodash"));
566 assert!(!graph.package_usage.contains_key("express"));
567 }
568
569 #[test]
570 fn graph_empty() {
571 let graph = ModuleGraph::build(&[], &[], &[]);
572 assert_eq!(graph.module_count(), 0);
573 assert_eq!(graph.edge_count(), 0);
574 }
575
576 #[test]
577 fn graph_cjs_exports_tracked() {
578 let files = vec![DiscoveredFile {
579 id: FileId(0),
580 path: PathBuf::from("/project/entry.ts"),
581 size_bytes: 100,
582 }];
583
584 let entry_points = vec![EntryPoint {
585 path: PathBuf::from("/project/entry.ts"),
586 source: EntryPointSource::PackageJsonMain,
587 }];
588
589 let resolved_modules = vec![ResolvedModule {
590 file_id: FileId(0),
591 path: PathBuf::from("/project/entry.ts"),
592 exports: vec![],
593 re_exports: vec![],
594 resolved_imports: vec![],
595 resolved_dynamic_imports: vec![],
596 resolved_dynamic_patterns: vec![],
597 member_accesses: vec![],
598 whole_object_uses: vec![],
599 has_cjs_exports: true,
600 unused_import_bindings: vec![],
601 }];
602
603 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
604 assert!(graph.modules[0].has_cjs_exports);
605 }
606}