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