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