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 runtime_entry_points: FxHashSet<FileId>,
41 pub test_entry_points: FxHashSet<FileId>,
43 pub reverse_deps: Vec<Vec<FileId>>,
45 namespace_imported: FixedBitSet,
47}
48
49#[derive(Debug)]
51pub(super) struct Edge {
52 pub(super) source: FileId,
53 pub(super) target: FileId,
54 pub(super) symbols: Vec<ImportedSymbol>,
55}
56
57#[derive(Debug)]
59pub(super) struct ImportedSymbol {
60 pub(super) imported_name: ImportedName,
61 pub(super) local_name: String,
62 pub(super) import_span: oxc_span::Span,
64 pub(super) is_type_only: bool,
67}
68
69#[cfg(target_pointer_width = "64")]
73const _: () = assert!(std::mem::size_of::<Edge>() == 32);
74#[cfg(target_pointer_width = "64")]
75const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
76
77impl ModuleGraph {
78 fn resolve_entry_point_ids(
79 entry_points: &[EntryPoint],
80 path_to_id: &FxHashMap<PathBuf, FileId>,
81 ) -> FxHashSet<FileId> {
82 entry_points
83 .iter()
84 .filter_map(|ep| {
85 path_to_id.get(&ep.path).copied().or_else(|| {
86 dunce::canonicalize(&ep.path)
87 .ok()
88 .and_then(|path| path_to_id.get(&path).copied())
89 })
90 })
91 .collect()
92 }
93
94 pub fn build(
96 resolved_modules: &[ResolvedModule],
97 entry_points: &[EntryPoint],
98 files: &[DiscoveredFile],
99 ) -> Self {
100 Self::build_with_reachability_roots(
101 resolved_modules,
102 entry_points,
103 entry_points,
104 &[],
105 files,
106 )
107 }
108
109 pub fn build_with_reachability_roots(
111 resolved_modules: &[ResolvedModule],
112 entry_points: &[EntryPoint],
113 runtime_entry_points: &[EntryPoint],
114 test_entry_points: &[EntryPoint],
115 files: &[DiscoveredFile],
116 ) -> Self {
117 let _span = tracing::info_span!("build_graph").entered();
118
119 let module_count = files.len();
120
121 let max_file_id = files
124 .iter()
125 .map(|f| f.id.0 as usize)
126 .max()
127 .map_or(0, |m| m + 1);
128 let total_capacity = max_file_id.max(module_count);
129
130 let path_to_id: FxHashMap<PathBuf, FileId> =
132 files.iter().map(|f| (f.path.clone(), f.id)).collect();
133
134 let module_by_id: FxHashMap<FileId, &ResolvedModule> =
136 resolved_modules.iter().map(|m| (m.file_id, m)).collect();
137
138 let entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
140 let runtime_entry_point_ids =
141 Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
142 let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
143
144 let mut graph = Self::populate_edges(
146 files,
147 &module_by_id,
148 &entry_point_ids,
149 &runtime_entry_point_ids,
150 &test_entry_point_ids,
151 module_count,
152 total_capacity,
153 );
154
155 graph.populate_references(&module_by_id, &entry_point_ids);
157
158 graph.mark_reachable(
160 &entry_point_ids,
161 &runtime_entry_point_ids,
162 &test_entry_point_ids,
163 total_capacity,
164 );
165
166 graph.resolve_re_export_chains();
168
169 graph
170 }
171
172 #[must_use]
174 pub const fn module_count(&self) -> usize {
175 self.modules.len()
176 }
177
178 #[must_use]
180 pub const fn edge_count(&self) -> usize {
181 self.edges.len()
182 }
183
184 #[must_use]
187 pub fn has_namespace_import(&self, file_id: FileId) -> bool {
188 let idx = file_id.0 as usize;
189 if idx >= self.namespace_imported.len() {
190 return false;
191 }
192 self.namespace_imported.contains(idx)
193 }
194
195 #[must_use]
197 pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
198 let idx = file_id.0 as usize;
199 if idx >= self.modules.len() {
200 return Vec::new();
201 }
202 let range = &self.modules[idx].edge_range;
203 self.edges[range.clone()].iter().map(|e| e.target).collect()
204 }
205
206 #[must_use]
209 pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
210 let idx = source.0 as usize;
211 if idx >= self.modules.len() {
212 return None;
213 }
214 let range = &self.modules[idx].edge_range;
215 for edge in &self.edges[range.clone()] {
216 if edge.target == target {
217 return edge.symbols.first().map(|s| s.import_span.start);
218 }
219 }
220 None
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
228 use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
229 use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
230 use std::path::PathBuf;
231
232 fn build_simple_graph() -> ModuleGraph {
234 let files = vec![
236 DiscoveredFile {
237 id: FileId(0),
238 path: PathBuf::from("/project/src/entry.ts"),
239 size_bytes: 100,
240 },
241 DiscoveredFile {
242 id: FileId(1),
243 path: PathBuf::from("/project/src/utils.ts"),
244 size_bytes: 50,
245 },
246 ];
247
248 let entry_points = vec![EntryPoint {
249 path: PathBuf::from("/project/src/entry.ts"),
250 source: EntryPointSource::PackageJsonMain,
251 }];
252
253 let resolved_modules = vec![
254 ResolvedModule {
255 file_id: FileId(0),
256 path: PathBuf::from("/project/src/entry.ts"),
257 exports: vec![],
258 re_exports: vec![],
259 resolved_imports: vec![ResolvedImport {
260 info: ImportInfo {
261 source: "./utils".to_string(),
262 imported_name: ImportedName::Named("foo".to_string()),
263 local_name: "foo".to_string(),
264 is_type_only: false,
265 span: oxc_span::Span::new(0, 10),
266 source_span: oxc_span::Span::default(),
267 },
268 target: ResolveResult::InternalModule(FileId(1)),
269 }],
270 resolved_dynamic_imports: vec![],
271 resolved_dynamic_patterns: vec![],
272 member_accesses: vec![],
273 whole_object_uses: vec![],
274 has_cjs_exports: false,
275 unused_import_bindings: FxHashSet::default(),
276 },
277 ResolvedModule {
278 file_id: FileId(1),
279 path: PathBuf::from("/project/src/utils.ts"),
280 exports: vec![
281 fallow_types::extract::ExportInfo {
282 name: ExportName::Named("foo".to_string()),
283 local_name: Some("foo".to_string()),
284 is_type_only: false,
285 is_public: false,
286 span: oxc_span::Span::new(0, 20),
287 members: vec![],
288 },
289 fallow_types::extract::ExportInfo {
290 name: ExportName::Named("bar".to_string()),
291 local_name: Some("bar".to_string()),
292 is_type_only: false,
293 is_public: false,
294 span: oxc_span::Span::new(25, 45),
295 members: vec![],
296 },
297 ],
298 re_exports: vec![],
299 resolved_imports: vec![],
300 resolved_dynamic_imports: vec![],
301 resolved_dynamic_patterns: vec![],
302 member_accesses: vec![],
303 whole_object_uses: vec![],
304 has_cjs_exports: false,
305 unused_import_bindings: FxHashSet::default(),
306 },
307 ];
308
309 ModuleGraph::build(&resolved_modules, &entry_points, &files)
310 }
311
312 #[test]
313 fn graph_module_count() {
314 let graph = build_simple_graph();
315 assert_eq!(graph.module_count(), 2);
316 }
317
318 #[test]
319 fn graph_edge_count() {
320 let graph = build_simple_graph();
321 assert_eq!(graph.edge_count(), 1);
322 }
323
324 #[test]
325 fn graph_entry_point_is_reachable() {
326 let graph = build_simple_graph();
327 assert!(graph.modules[0].is_entry_point);
328 assert!(graph.modules[0].is_reachable);
329 }
330
331 #[test]
332 fn graph_imported_module_is_reachable() {
333 let graph = build_simple_graph();
334 assert!(!graph.modules[1].is_entry_point);
335 assert!(graph.modules[1].is_reachable);
336 }
337
338 #[test]
339 fn graph_distinguishes_runtime_test_and_support_reachability() {
340 let files = vec![
341 DiscoveredFile {
342 id: FileId(0),
343 path: PathBuf::from("/project/src/main.ts"),
344 size_bytes: 100,
345 },
346 DiscoveredFile {
347 id: FileId(1),
348 path: PathBuf::from("/project/src/runtime-only.ts"),
349 size_bytes: 50,
350 },
351 DiscoveredFile {
352 id: FileId(2),
353 path: PathBuf::from("/project/tests/app.test.ts"),
354 size_bytes: 50,
355 },
356 DiscoveredFile {
357 id: FileId(3),
358 path: PathBuf::from("/project/tests/setup.ts"),
359 size_bytes: 50,
360 },
361 DiscoveredFile {
362 id: FileId(4),
363 path: PathBuf::from("/project/src/covered.ts"),
364 size_bytes: 50,
365 },
366 ];
367
368 let all_entry_points = vec![
369 EntryPoint {
370 path: PathBuf::from("/project/src/main.ts"),
371 source: EntryPointSource::PackageJsonMain,
372 },
373 EntryPoint {
374 path: PathBuf::from("/project/tests/app.test.ts"),
375 source: EntryPointSource::TestFile,
376 },
377 EntryPoint {
378 path: PathBuf::from("/project/tests/setup.ts"),
379 source: EntryPointSource::Plugin {
380 name: "vitest".to_string(),
381 },
382 },
383 ];
384 let runtime_entry_points = vec![EntryPoint {
385 path: PathBuf::from("/project/src/main.ts"),
386 source: EntryPointSource::PackageJsonMain,
387 }];
388 let test_entry_points = vec![EntryPoint {
389 path: PathBuf::from("/project/tests/app.test.ts"),
390 source: EntryPointSource::TestFile,
391 }];
392
393 let resolved_modules = vec![
394 ResolvedModule {
395 file_id: FileId(0),
396 path: PathBuf::from("/project/src/main.ts"),
397 exports: vec![],
398 re_exports: vec![],
399 resolved_imports: vec![ResolvedImport {
400 info: ImportInfo {
401 source: "./runtime-only".to_string(),
402 imported_name: ImportedName::Named("runtimeOnly".to_string()),
403 local_name: "runtimeOnly".to_string(),
404 is_type_only: false,
405 span: oxc_span::Span::new(0, 10),
406 source_span: oxc_span::Span::default(),
407 },
408 target: ResolveResult::InternalModule(FileId(1)),
409 }],
410 resolved_dynamic_imports: vec![],
411 resolved_dynamic_patterns: vec![],
412 member_accesses: vec![],
413 whole_object_uses: vec![],
414 has_cjs_exports: false,
415 unused_import_bindings: FxHashSet::default(),
416 },
417 ResolvedModule {
418 file_id: FileId(1),
419 path: PathBuf::from("/project/src/runtime-only.ts"),
420 exports: vec![fallow_types::extract::ExportInfo {
421 name: ExportName::Named("runtimeOnly".to_string()),
422 local_name: Some("runtimeOnly".to_string()),
423 is_type_only: false,
424 is_public: false,
425 span: oxc_span::Span::new(0, 20),
426 members: vec![],
427 }],
428 re_exports: vec![],
429 resolved_imports: vec![],
430 resolved_dynamic_imports: vec![],
431 resolved_dynamic_patterns: vec![],
432 member_accesses: vec![],
433 whole_object_uses: vec![],
434 has_cjs_exports: false,
435 unused_import_bindings: FxHashSet::default(),
436 },
437 ResolvedModule {
438 file_id: FileId(2),
439 path: PathBuf::from("/project/tests/app.test.ts"),
440 exports: vec![],
441 re_exports: vec![],
442 resolved_imports: vec![ResolvedImport {
443 info: ImportInfo {
444 source: "../src/covered".to_string(),
445 imported_name: ImportedName::Named("covered".to_string()),
446 local_name: "covered".to_string(),
447 is_type_only: false,
448 span: oxc_span::Span::new(0, 10),
449 source_span: oxc_span::Span::default(),
450 },
451 target: ResolveResult::InternalModule(FileId(4)),
452 }],
453 resolved_dynamic_imports: vec![],
454 resolved_dynamic_patterns: vec![],
455 member_accesses: vec![],
456 whole_object_uses: vec![],
457 has_cjs_exports: false,
458 unused_import_bindings: FxHashSet::default(),
459 },
460 ResolvedModule {
461 file_id: FileId(3),
462 path: PathBuf::from("/project/tests/setup.ts"),
463 exports: vec![],
464 re_exports: vec![],
465 resolved_imports: vec![ResolvedImport {
466 info: ImportInfo {
467 source: "../src/runtime-only".to_string(),
468 imported_name: ImportedName::Named("runtimeOnly".to_string()),
469 local_name: "runtimeOnly".to_string(),
470 is_type_only: false,
471 span: oxc_span::Span::new(0, 10),
472 source_span: oxc_span::Span::default(),
473 },
474 target: ResolveResult::InternalModule(FileId(1)),
475 }],
476 resolved_dynamic_imports: vec![],
477 resolved_dynamic_patterns: vec![],
478 member_accesses: vec![],
479 whole_object_uses: vec![],
480 has_cjs_exports: false,
481 unused_import_bindings: FxHashSet::default(),
482 },
483 ResolvedModule {
484 file_id: FileId(4),
485 path: PathBuf::from("/project/src/covered.ts"),
486 exports: vec![fallow_types::extract::ExportInfo {
487 name: ExportName::Named("covered".to_string()),
488 local_name: Some("covered".to_string()),
489 is_type_only: false,
490 is_public: false,
491 span: oxc_span::Span::new(0, 20),
492 members: vec![],
493 }],
494 re_exports: vec![],
495 resolved_imports: vec![],
496 resolved_dynamic_imports: vec![],
497 resolved_dynamic_patterns: vec![],
498 member_accesses: vec![],
499 whole_object_uses: vec![],
500 has_cjs_exports: false,
501 unused_import_bindings: FxHashSet::default(),
502 },
503 ];
504
505 let graph = ModuleGraph::build_with_reachability_roots(
506 &resolved_modules,
507 &all_entry_points,
508 &runtime_entry_points,
509 &test_entry_points,
510 &files,
511 );
512
513 assert!(graph.modules[1].is_reachable);
514 assert!(graph.modules[1].is_runtime_reachable);
515 assert!(
516 !graph.modules[1].is_test_reachable,
517 "support roots should not make runtime-only modules test reachable"
518 );
519
520 assert!(graph.modules[4].is_reachable);
521 assert!(graph.modules[4].is_test_reachable);
522 assert!(
523 !graph.modules[4].is_runtime_reachable,
524 "test-only reachability should stay separate from runtime roots"
525 );
526 }
527
528 #[test]
529 fn graph_export_has_reference() {
530 let graph = build_simple_graph();
531 let utils = &graph.modules[1];
532 let foo_export = utils
533 .exports
534 .iter()
535 .find(|e| e.name.to_string() == "foo")
536 .unwrap();
537 assert!(
538 !foo_export.references.is_empty(),
539 "foo should have references"
540 );
541 }
542
543 #[test]
544 fn graph_unused_export_no_reference() {
545 let graph = build_simple_graph();
546 let utils = &graph.modules[1];
547 let bar_export = utils
548 .exports
549 .iter()
550 .find(|e| e.name.to_string() == "bar")
551 .unwrap();
552 assert!(
553 bar_export.references.is_empty(),
554 "bar should have no references"
555 );
556 }
557
558 #[test]
559 fn graph_no_namespace_import() {
560 let graph = build_simple_graph();
561 assert!(!graph.has_namespace_import(FileId(0)));
562 assert!(!graph.has_namespace_import(FileId(1)));
563 }
564
565 #[test]
566 fn graph_has_namespace_import() {
567 let files = vec![
568 DiscoveredFile {
569 id: FileId(0),
570 path: PathBuf::from("/project/entry.ts"),
571 size_bytes: 100,
572 },
573 DiscoveredFile {
574 id: FileId(1),
575 path: PathBuf::from("/project/utils.ts"),
576 size_bytes: 50,
577 },
578 ];
579
580 let entry_points = vec![EntryPoint {
581 path: PathBuf::from("/project/entry.ts"),
582 source: EntryPointSource::PackageJsonMain,
583 }];
584
585 let resolved_modules = vec![
586 ResolvedModule {
587 file_id: FileId(0),
588 path: PathBuf::from("/project/entry.ts"),
589 exports: vec![],
590 re_exports: vec![],
591 resolved_imports: vec![ResolvedImport {
592 info: ImportInfo {
593 source: "./utils".to_string(),
594 imported_name: ImportedName::Namespace,
595 local_name: "utils".to_string(),
596 is_type_only: false,
597 span: oxc_span::Span::new(0, 10),
598 source_span: oxc_span::Span::default(),
599 },
600 target: ResolveResult::InternalModule(FileId(1)),
601 }],
602 resolved_dynamic_imports: vec![],
603 resolved_dynamic_patterns: vec![],
604 member_accesses: vec![],
605 whole_object_uses: vec![],
606 has_cjs_exports: false,
607 unused_import_bindings: FxHashSet::default(),
608 },
609 ResolvedModule {
610 file_id: FileId(1),
611 path: PathBuf::from("/project/utils.ts"),
612 exports: vec![fallow_types::extract::ExportInfo {
613 name: ExportName::Named("foo".to_string()),
614 local_name: Some("foo".to_string()),
615 is_type_only: false,
616 is_public: false,
617 span: oxc_span::Span::new(0, 20),
618 members: vec![],
619 }],
620 re_exports: vec![],
621 resolved_imports: vec![],
622 resolved_dynamic_imports: vec![],
623 resolved_dynamic_patterns: vec![],
624 member_accesses: vec![],
625 whole_object_uses: vec![],
626 has_cjs_exports: false,
627 unused_import_bindings: FxHashSet::default(),
628 },
629 ];
630
631 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
632 assert!(
633 graph.has_namespace_import(FileId(1)),
634 "utils should have namespace import"
635 );
636 }
637
638 #[test]
639 fn graph_has_namespace_import_out_of_bounds() {
640 let graph = build_simple_graph();
641 assert!(!graph.has_namespace_import(FileId(999)));
642 }
643
644 #[test]
645 fn graph_unreachable_module() {
646 let files = vec![
648 DiscoveredFile {
649 id: FileId(0),
650 path: PathBuf::from("/project/entry.ts"),
651 size_bytes: 100,
652 },
653 DiscoveredFile {
654 id: FileId(1),
655 path: PathBuf::from("/project/utils.ts"),
656 size_bytes: 50,
657 },
658 DiscoveredFile {
659 id: FileId(2),
660 path: PathBuf::from("/project/orphan.ts"),
661 size_bytes: 30,
662 },
663 ];
664
665 let entry_points = vec![EntryPoint {
666 path: PathBuf::from("/project/entry.ts"),
667 source: EntryPointSource::PackageJsonMain,
668 }];
669
670 let resolved_modules = vec![
671 ResolvedModule {
672 file_id: FileId(0),
673 path: PathBuf::from("/project/entry.ts"),
674 exports: vec![],
675 re_exports: vec![],
676 resolved_imports: vec![ResolvedImport {
677 info: ImportInfo {
678 source: "./utils".to_string(),
679 imported_name: ImportedName::Named("foo".to_string()),
680 local_name: "foo".to_string(),
681 is_type_only: false,
682 span: oxc_span::Span::new(0, 10),
683 source_span: oxc_span::Span::default(),
684 },
685 target: ResolveResult::InternalModule(FileId(1)),
686 }],
687 resolved_dynamic_imports: vec![],
688 resolved_dynamic_patterns: vec![],
689 member_accesses: vec![],
690 whole_object_uses: vec![],
691 has_cjs_exports: false,
692 unused_import_bindings: FxHashSet::default(),
693 },
694 ResolvedModule {
695 file_id: FileId(1),
696 path: PathBuf::from("/project/utils.ts"),
697 exports: vec![fallow_types::extract::ExportInfo {
698 name: ExportName::Named("foo".to_string()),
699 local_name: Some("foo".to_string()),
700 is_type_only: false,
701 is_public: false,
702 span: oxc_span::Span::new(0, 20),
703 members: vec![],
704 }],
705 re_exports: vec![],
706 resolved_imports: vec![],
707 resolved_dynamic_imports: vec![],
708 resolved_dynamic_patterns: vec![],
709 member_accesses: vec![],
710 whole_object_uses: vec![],
711 has_cjs_exports: false,
712 unused_import_bindings: FxHashSet::default(),
713 },
714 ResolvedModule {
715 file_id: FileId(2),
716 path: PathBuf::from("/project/orphan.ts"),
717 exports: vec![fallow_types::extract::ExportInfo {
718 name: ExportName::Named("orphan".to_string()),
719 local_name: Some("orphan".to_string()),
720 is_type_only: false,
721 is_public: false,
722 span: oxc_span::Span::new(0, 20),
723 members: vec![],
724 }],
725 re_exports: vec![],
726 resolved_imports: vec![],
727 resolved_dynamic_imports: vec![],
728 resolved_dynamic_patterns: vec![],
729 member_accesses: vec![],
730 whole_object_uses: vec![],
731 has_cjs_exports: false,
732 unused_import_bindings: FxHashSet::default(),
733 },
734 ];
735
736 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
737
738 assert!(graph.modules[0].is_reachable, "entry should be reachable");
739 assert!(graph.modules[1].is_reachable, "utils should be reachable");
740 assert!(
741 !graph.modules[2].is_reachable,
742 "orphan should NOT be reachable"
743 );
744 }
745
746 #[test]
747 fn graph_package_usage_tracked() {
748 let files = vec![DiscoveredFile {
749 id: FileId(0),
750 path: PathBuf::from("/project/entry.ts"),
751 size_bytes: 100,
752 }];
753
754 let entry_points = vec![EntryPoint {
755 path: PathBuf::from("/project/entry.ts"),
756 source: EntryPointSource::PackageJsonMain,
757 }];
758
759 let resolved_modules = vec![ResolvedModule {
760 file_id: FileId(0),
761 path: PathBuf::from("/project/entry.ts"),
762 exports: vec![],
763 re_exports: vec![],
764 resolved_imports: vec![
765 ResolvedImport {
766 info: ImportInfo {
767 source: "react".to_string(),
768 imported_name: ImportedName::Default,
769 local_name: "React".to_string(),
770 is_type_only: false,
771 span: oxc_span::Span::new(0, 10),
772 source_span: oxc_span::Span::default(),
773 },
774 target: ResolveResult::NpmPackage("react".to_string()),
775 },
776 ResolvedImport {
777 info: ImportInfo {
778 source: "lodash".to_string(),
779 imported_name: ImportedName::Named("merge".to_string()),
780 local_name: "merge".to_string(),
781 is_type_only: false,
782 span: oxc_span::Span::new(15, 30),
783 source_span: oxc_span::Span::default(),
784 },
785 target: ResolveResult::NpmPackage("lodash".to_string()),
786 },
787 ],
788 resolved_dynamic_imports: vec![],
789 resolved_dynamic_patterns: vec![],
790 member_accesses: vec![],
791 whole_object_uses: vec![],
792 has_cjs_exports: false,
793 unused_import_bindings: FxHashSet::default(),
794 }];
795
796 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
797 assert!(graph.package_usage.contains_key("react"));
798 assert!(graph.package_usage.contains_key("lodash"));
799 assert!(!graph.package_usage.contains_key("express"));
800 }
801
802 #[test]
803 fn graph_empty() {
804 let graph = ModuleGraph::build(&[], &[], &[]);
805 assert_eq!(graph.module_count(), 0);
806 assert_eq!(graph.edge_count(), 0);
807 }
808
809 #[test]
810 fn graph_cjs_exports_tracked() {
811 let files = vec![DiscoveredFile {
812 id: FileId(0),
813 path: PathBuf::from("/project/entry.ts"),
814 size_bytes: 100,
815 }];
816
817 let entry_points = vec![EntryPoint {
818 path: PathBuf::from("/project/entry.ts"),
819 source: EntryPointSource::PackageJsonMain,
820 }];
821
822 let resolved_modules = vec![ResolvedModule {
823 file_id: FileId(0),
824 path: PathBuf::from("/project/entry.ts"),
825 exports: vec![],
826 re_exports: vec![],
827 resolved_imports: vec![],
828 resolved_dynamic_imports: vec![],
829 resolved_dynamic_patterns: vec![],
830 member_accesses: vec![],
831 whole_object_uses: vec![],
832 has_cjs_exports: true,
833 unused_import_bindings: FxHashSet::default(),
834 }];
835
836 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
837 assert!(graph.modules[0].has_cjs_exports);
838 }
839
840 #[test]
841 fn graph_edges_for_returns_targets() {
842 let graph = build_simple_graph();
843 let targets = graph.edges_for(FileId(0));
844 assert_eq!(targets, vec![FileId(1)]);
845 }
846
847 #[test]
848 fn graph_edges_for_no_imports() {
849 let graph = build_simple_graph();
850 let targets = graph.edges_for(FileId(1));
852 assert!(targets.is_empty());
853 }
854
855 #[test]
856 fn graph_edges_for_out_of_bounds() {
857 let graph = build_simple_graph();
858 let targets = graph.edges_for(FileId(999));
859 assert!(targets.is_empty());
860 }
861
862 #[test]
863 fn graph_find_import_span_start_found() {
864 let graph = build_simple_graph();
865 let span_start = graph.find_import_span_start(FileId(0), FileId(1));
866 assert!(span_start.is_some());
867 assert_eq!(span_start.unwrap(), 0);
868 }
869
870 #[test]
871 fn graph_find_import_span_start_wrong_target() {
872 let graph = build_simple_graph();
873 let span_start = graph.find_import_span_start(FileId(0), FileId(0));
875 assert!(span_start.is_none());
876 }
877
878 #[test]
879 fn graph_find_import_span_start_source_out_of_bounds() {
880 let graph = build_simple_graph();
881 let span_start = graph.find_import_span_start(FileId(999), FileId(1));
882 assert!(span_start.is_none());
883 }
884
885 #[test]
886 fn graph_find_import_span_start_no_edges() {
887 let graph = build_simple_graph();
888 let span_start = graph.find_import_span_start(FileId(1), FileId(0));
890 assert!(span_start.is_none());
891 }
892
893 #[test]
894 fn graph_reverse_deps_populated() {
895 let graph = build_simple_graph();
896 assert!(graph.reverse_deps[1].contains(&FileId(0)));
898 assert!(graph.reverse_deps[0].is_empty());
900 }
901
902 #[test]
903 fn graph_type_only_package_usage_tracked() {
904 let files = vec![DiscoveredFile {
905 id: FileId(0),
906 path: PathBuf::from("/project/entry.ts"),
907 size_bytes: 100,
908 }];
909 let entry_points = vec![EntryPoint {
910 path: PathBuf::from("/project/entry.ts"),
911 source: EntryPointSource::PackageJsonMain,
912 }];
913 let resolved_modules = vec![ResolvedModule {
914 file_id: FileId(0),
915 path: PathBuf::from("/project/entry.ts"),
916 exports: vec![],
917 re_exports: vec![],
918 resolved_imports: vec![
919 ResolvedImport {
920 info: ImportInfo {
921 source: "react".to_string(),
922 imported_name: ImportedName::Named("FC".to_string()),
923 local_name: "FC".to_string(),
924 is_type_only: true,
925 span: oxc_span::Span::new(0, 10),
926 source_span: oxc_span::Span::default(),
927 },
928 target: ResolveResult::NpmPackage("react".to_string()),
929 },
930 ResolvedImport {
931 info: ImportInfo {
932 source: "react".to_string(),
933 imported_name: ImportedName::Named("useState".to_string()),
934 local_name: "useState".to_string(),
935 is_type_only: false,
936 span: oxc_span::Span::new(15, 30),
937 source_span: oxc_span::Span::default(),
938 },
939 target: ResolveResult::NpmPackage("react".to_string()),
940 },
941 ],
942 resolved_dynamic_imports: vec![],
943 resolved_dynamic_patterns: vec![],
944 member_accesses: vec![],
945 whole_object_uses: vec![],
946 has_cjs_exports: false,
947 unused_import_bindings: FxHashSet::default(),
948 }];
949
950 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
951 assert!(graph.package_usage.contains_key("react"));
952 assert!(graph.type_only_package_usage.contains_key("react"));
953 }
954
955 #[test]
956 fn graph_default_import_reference() {
957 let files = vec![
958 DiscoveredFile {
959 id: FileId(0),
960 path: PathBuf::from("/project/entry.ts"),
961 size_bytes: 100,
962 },
963 DiscoveredFile {
964 id: FileId(1),
965 path: PathBuf::from("/project/utils.ts"),
966 size_bytes: 50,
967 },
968 ];
969 let entry_points = vec![EntryPoint {
970 path: PathBuf::from("/project/entry.ts"),
971 source: EntryPointSource::PackageJsonMain,
972 }];
973 let resolved_modules = vec![
974 ResolvedModule {
975 file_id: FileId(0),
976 path: PathBuf::from("/project/entry.ts"),
977 exports: vec![],
978 re_exports: vec![],
979 resolved_imports: vec![ResolvedImport {
980 info: ImportInfo {
981 source: "./utils".to_string(),
982 imported_name: ImportedName::Default,
983 local_name: "Utils".to_string(),
984 is_type_only: false,
985 span: oxc_span::Span::new(0, 10),
986 source_span: oxc_span::Span::default(),
987 },
988 target: ResolveResult::InternalModule(FileId(1)),
989 }],
990 resolved_dynamic_imports: vec![],
991 resolved_dynamic_patterns: vec![],
992 member_accesses: vec![],
993 whole_object_uses: vec![],
994 has_cjs_exports: false,
995 unused_import_bindings: FxHashSet::default(),
996 },
997 ResolvedModule {
998 file_id: FileId(1),
999 path: PathBuf::from("/project/utils.ts"),
1000 exports: vec![fallow_types::extract::ExportInfo {
1001 name: ExportName::Default,
1002 local_name: None,
1003 is_type_only: false,
1004 is_public: false,
1005 span: oxc_span::Span::new(0, 20),
1006 members: vec![],
1007 }],
1008 re_exports: vec![],
1009 resolved_imports: vec![],
1010 resolved_dynamic_imports: vec![],
1011 resolved_dynamic_patterns: vec![],
1012 member_accesses: vec![],
1013 whole_object_uses: vec![],
1014 has_cjs_exports: false,
1015 unused_import_bindings: FxHashSet::default(),
1016 },
1017 ];
1018
1019 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1020 let utils = &graph.modules[1];
1021 let default_export = utils
1022 .exports
1023 .iter()
1024 .find(|e| matches!(e.name, ExportName::Default))
1025 .unwrap();
1026 assert!(!default_export.references.is_empty());
1027 assert_eq!(
1028 default_export.references[0].kind,
1029 ReferenceKind::DefaultImport
1030 );
1031 }
1032
1033 #[test]
1034 fn graph_side_effect_import_no_export_reference() {
1035 let files = vec![
1036 DiscoveredFile {
1037 id: FileId(0),
1038 path: PathBuf::from("/project/entry.ts"),
1039 size_bytes: 100,
1040 },
1041 DiscoveredFile {
1042 id: FileId(1),
1043 path: PathBuf::from("/project/styles.ts"),
1044 size_bytes: 50,
1045 },
1046 ];
1047 let entry_points = vec![EntryPoint {
1048 path: PathBuf::from("/project/entry.ts"),
1049 source: EntryPointSource::PackageJsonMain,
1050 }];
1051 let resolved_modules = vec![
1052 ResolvedModule {
1053 file_id: FileId(0),
1054 path: PathBuf::from("/project/entry.ts"),
1055 exports: vec![],
1056 re_exports: vec![],
1057 resolved_imports: vec![ResolvedImport {
1058 info: ImportInfo {
1059 source: "./styles".to_string(),
1060 imported_name: ImportedName::SideEffect,
1061 local_name: String::new(),
1062 is_type_only: false,
1063 span: oxc_span::Span::new(0, 10),
1064 source_span: oxc_span::Span::default(),
1065 },
1066 target: ResolveResult::InternalModule(FileId(1)),
1067 }],
1068 resolved_dynamic_imports: vec![],
1069 resolved_dynamic_patterns: vec![],
1070 member_accesses: vec![],
1071 whole_object_uses: vec![],
1072 has_cjs_exports: false,
1073 unused_import_bindings: FxHashSet::default(),
1074 },
1075 ResolvedModule {
1076 file_id: FileId(1),
1077 path: PathBuf::from("/project/styles.ts"),
1078 exports: vec![fallow_types::extract::ExportInfo {
1079 name: ExportName::Named("primaryColor".to_string()),
1080 local_name: Some("primaryColor".to_string()),
1081 is_type_only: false,
1082 is_public: false,
1083 span: oxc_span::Span::new(0, 20),
1084 members: vec![],
1085 }],
1086 re_exports: vec![],
1087 resolved_imports: vec![],
1088 resolved_dynamic_imports: vec![],
1089 resolved_dynamic_patterns: vec![],
1090 member_accesses: vec![],
1091 whole_object_uses: vec![],
1092 has_cjs_exports: false,
1093 unused_import_bindings: FxHashSet::default(),
1094 },
1095 ];
1096
1097 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1098 assert_eq!(graph.edge_count(), 1);
1100 let styles = &graph.modules[1];
1101 let export = &styles.exports[0];
1102 assert!(
1104 export.references.is_empty(),
1105 "side-effect import should not reference named exports"
1106 );
1107 }
1108
1109 #[test]
1110 fn graph_multiple_entry_points() {
1111 let files = vec![
1112 DiscoveredFile {
1113 id: FileId(0),
1114 path: PathBuf::from("/project/main.ts"),
1115 size_bytes: 100,
1116 },
1117 DiscoveredFile {
1118 id: FileId(1),
1119 path: PathBuf::from("/project/worker.ts"),
1120 size_bytes: 100,
1121 },
1122 DiscoveredFile {
1123 id: FileId(2),
1124 path: PathBuf::from("/project/shared.ts"),
1125 size_bytes: 50,
1126 },
1127 ];
1128 let entry_points = vec![
1129 EntryPoint {
1130 path: PathBuf::from("/project/main.ts"),
1131 source: EntryPointSource::PackageJsonMain,
1132 },
1133 EntryPoint {
1134 path: PathBuf::from("/project/worker.ts"),
1135 source: EntryPointSource::PackageJsonMain,
1136 },
1137 ];
1138 let resolved_modules = vec![
1139 ResolvedModule {
1140 file_id: FileId(0),
1141 path: PathBuf::from("/project/main.ts"),
1142 exports: vec![],
1143 re_exports: vec![],
1144 resolved_imports: vec![ResolvedImport {
1145 info: ImportInfo {
1146 source: "./shared".to_string(),
1147 imported_name: ImportedName::Named("helper".to_string()),
1148 local_name: "helper".to_string(),
1149 is_type_only: false,
1150 span: oxc_span::Span::new(0, 10),
1151 source_span: oxc_span::Span::default(),
1152 },
1153 target: ResolveResult::InternalModule(FileId(2)),
1154 }],
1155 resolved_dynamic_imports: vec![],
1156 resolved_dynamic_patterns: vec![],
1157 member_accesses: vec![],
1158 whole_object_uses: vec![],
1159 has_cjs_exports: false,
1160 unused_import_bindings: FxHashSet::default(),
1161 },
1162 ResolvedModule {
1163 file_id: FileId(1),
1164 path: PathBuf::from("/project/worker.ts"),
1165 exports: vec![],
1166 re_exports: vec![],
1167 resolved_imports: vec![],
1168 resolved_dynamic_imports: vec![],
1169 resolved_dynamic_patterns: vec![],
1170 member_accesses: vec![],
1171 whole_object_uses: vec![],
1172 has_cjs_exports: false,
1173 unused_import_bindings: FxHashSet::default(),
1174 },
1175 ResolvedModule {
1176 file_id: FileId(2),
1177 path: PathBuf::from("/project/shared.ts"),
1178 exports: vec![fallow_types::extract::ExportInfo {
1179 name: ExportName::Named("helper".to_string()),
1180 local_name: Some("helper".to_string()),
1181 is_type_only: false,
1182 is_public: false,
1183 span: oxc_span::Span::new(0, 20),
1184 members: vec![],
1185 }],
1186 re_exports: vec![],
1187 resolved_imports: vec![],
1188 resolved_dynamic_imports: vec![],
1189 resolved_dynamic_patterns: vec![],
1190 member_accesses: vec![],
1191 whole_object_uses: vec![],
1192 has_cjs_exports: false,
1193 unused_import_bindings: FxHashSet::default(),
1194 },
1195 ];
1196
1197 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1198 assert!(graph.modules[0].is_entry_point);
1199 assert!(graph.modules[1].is_entry_point);
1200 assert!(!graph.modules[2].is_entry_point);
1201 assert!(graph.modules[0].is_reachable);
1203 assert!(graph.modules[1].is_reachable);
1204 assert!(graph.modules[2].is_reachable);
1205 }
1206}