1use std::fmt::Write as _;
10use std::path::{Path, PathBuf};
11
12use fallow_types::discover::FileId;
13use fallow_types::extract::{
14 ExportName, ImportBindingReference, ImportBindingReferenceKind, ImportedName, ModuleInfo,
15 byte_offset_to_line_col,
16};
17use fallow_types::trace_usage::{
18 ClosureFile, ConsumerClosure, DependencyUsage, DependencyUsageQuery,
19 DependencyUsageSchemaVersion, FileLevelUnresolved, SpecifierUnresolved, SpecifierUsage,
20 UsageConfidence, UsageSite, UsageSiteKind, UsageSitePage, UsageWrapper, WrapperShape,
21};
22use rustc_hash::{FxHashMap, FxHashSet};
23
24use super::trace_impl::relativize;
25use crate::graph::ModuleGraph;
26
27const NAMESPACE_SPECIFIER: &str = "*";
29const DEFAULT_SPECIFIER: &str = "default";
31const CURSOR_PREFIX: &str = "v1.";
33const CURSOR_SEPARATOR: char = '\0';
35const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
36const FNV_PRIME: u64 = 0x0100_0000_01b3;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum UsageError {
41 InvalidCursor,
43}
44
45impl std::fmt::Display for UsageError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 Self::InvalidCursor => {
49 f.write_str("the cursor is not valid for this query; start again without --cursor")
50 }
51 }
52 }
53}
54
55impl std::error::Error for UsageError {}
56
57pub fn dependency_usage(
68 graph: &ModuleGraph,
69 modules: &[ModuleInfo],
70 root: &Path,
71 package_name: &str,
72 imported_by: &[PathBuf],
73 query: &DependencyUsageQuery,
74) -> Result<DependencyUsage, UsageError> {
75 let ctx = UsageContext::new(graph, modules, root, package_name);
76 let mut scan = Scan::default();
77 for module in modules {
78 scan.scan_module(&ctx, module);
79 }
80 scan.follow_wrappers(&ctx);
81
82 let selected = selected_specifiers(&query.specifiers);
83 let mut unresolved = scan.file_level_unresolved();
84 unresolved.unattributed_file = imported_by
85 .iter()
86 .filter(|path| {
87 ctx.file_id(&path_key(path))
88 .is_none_or(|id| !scan.attributed_files.contains(&id))
89 })
90 .count();
91 let specifiers = scan.specifier_usages(selected.as_deref());
92 let sites = match &query.sites {
93 Some(request) => Some(site_page(
94 &scan.sites,
95 selected.as_deref(),
96 package_name,
97 request.limit,
98 request.cursor.as_deref(),
99 )?),
100 None => None,
101 };
102 let closure = query.closure_depth.map(|depth| {
103 let seeds = closure_seeds(&ctx, &scan, imported_by, selected.as_deref());
104 consumer_closure(graph, root, &seeds, depth)
105 });
106 Ok(DependencyUsage {
107 schema_version: DependencyUsageSchemaVersion::V1,
108 confidence: UsageConfidence::Syntactic,
109 specifiers,
110 unresolved,
111 sites,
112 closure,
113 })
114}
115
116fn selected_specifiers(specifiers: &[String]) -> Option<Vec<String>> {
118 if specifiers.is_empty() {
119 return None;
120 }
121 let mut selected = specifiers.to_vec();
122 selected.sort_unstable();
123 selected.dedup();
124 Some(selected)
125}
126
127fn path_key(path: &Path) -> String {
128 path.to_string_lossy().replace('\\', "/")
129}
130
131struct UsageContext<'a> {
132 graph: &'a ModuleGraph,
133 root: &'a Path,
134 package_name: &'a str,
135 modules_by_id: FxHashMap<FileId, &'a ModuleInfo>,
136 files_by_path: FxHashMap<String, FileId>,
137}
138
139impl<'a> UsageContext<'a> {
140 fn new(
141 graph: &'a ModuleGraph,
142 modules: &'a [ModuleInfo],
143 root: &'a Path,
144 package_name: &'a str,
145 ) -> Self {
146 let modules_by_id = modules
147 .iter()
148 .map(|module| (module.file_id, module))
149 .collect();
150 let files_by_path = graph
151 .modules
152 .iter()
153 .map(|node| (relativize(&node.path, root), node.file_id))
154 .collect();
155 Self {
156 graph,
157 root,
158 package_name,
159 modules_by_id,
160 files_by_path,
161 }
162 }
163
164 fn file_id(&self, path: &str) -> Option<FileId> {
165 self.files_by_path.get(path).copied()
166 }
167
168 fn file_path(&self, id: FileId) -> Option<String> {
169 self.graph
170 .modules
171 .get(id.0 as usize)
172 .map(|node| relativize(&node.path, self.root))
173 }
174
175 fn names_package(&self, source: &str) -> bool {
178 if let Some(rest) = source.strip_prefix("npm:") {
179 return fallow_graph::resolve::extract_package_name(
180 &fallow_graph::resolve::normalize_npm_specifier(rest),
181 ) == self.package_name;
182 }
183 source.starts_with(self.package_name)
184 && fallow_graph::resolve::extract_package_name(source) == self.package_name
185 }
186}
187
188#[derive(Clone)]
190enum BindingName {
191 Named(String),
193 Namespace,
195}
196
197impl BindingName {
198 fn split(&self, member_path: &str) -> (String, Option<String>) {
200 match self {
201 Self::Named(name) => (name.clone(), non_empty(member_path)),
202 Self::Namespace => match member_path.split_once('.') {
203 Some((first, rest)) => (first.to_owned(), non_empty(rest)),
204 None if member_path.is_empty() => (NAMESPACE_SPECIFIER.to_owned(), None),
205 None => (member_path.to_owned(), None),
206 },
207 }
208 }
209}
210
211fn non_empty(value: &str) -> Option<String> {
212 (!value.is_empty()).then(|| value.to_owned())
213}
214
215#[derive(Default)]
217struct FileSpecifier {
218 runtime_binding: bool,
219 type_binding: bool,
220}
221
222struct Wrapper {
224 file_id: FileId,
225 file: String,
226 export: String,
227 declared: String,
229 owns_module_uses: bool,
233 specifier: String,
234 shape: WrapperShape,
235 line: u32,
236}
237
238impl Wrapper {
239 fn via(&self) -> String {
240 format!("{}:{}", self.file, self.export)
241 }
242}
243
244#[derive(Default)]
246struct WrapperCounts<'w> {
247 wrapper: Option<&'w Wrapper>,
248 calls: usize,
249 files: FxHashSet<FileId>,
250}
251
252struct RawSite {
254 file_id: FileId,
255 site: UsageSite,
256 counts_as_call: bool,
259}
260
261struct ViaSite<'a> {
263 kind: UsageSiteKind,
264 offset: u32,
265 local_name: &'a str,
266 member: Option<String>,
267}
268
269#[derive(Clone, Copy)]
271enum WrapperUse {
272 Call,
274 Reference(ImportBindingReferenceKind),
276}
277
278#[derive(Default)]
279struct Scan {
280 sites: Vec<RawSite>,
281 file_specifiers: FxHashMap<(FileId, String), FileSpecifier>,
283 sited: FxHashSet<(FileId, String)>,
285 wrappers: Vec<Wrapper>,
286 attributed_files: FxHashSet<FileId>,
289}
290
291struct ModuleScan<'m> {
293 module: &'m ModuleInfo,
294 file: String,
295 bindings: FxHashMap<u32, BindingName>,
297 locals: FxHashMap<&'m str, u32>,
299 first_site: usize,
301}
302
303impl ModuleScan<'_> {
304 fn position(&self, offset: u32) -> (u32, u32) {
305 byte_offset_to_line_col(&self.module.line_offsets, offset)
306 }
307}
308
309struct SiteSpec {
311 offset: u32,
312 specifier: Option<String>,
313 local_name: Option<String>,
314 member: Option<String>,
315 kind: UsageSiteKind,
316 via: Option<String>,
317 counts_as_call: bool,
318}
319
320impl Scan {
321 fn push_site(&mut self, scan: &ModuleScan<'_>, spec: SiteSpec) {
322 let (line, col) = scan.position(spec.offset);
323 if let Some(specifier) = &spec.specifier {
324 self.sited.insert((scan.module.file_id, specifier.clone()));
325 }
326 self.sites.push(RawSite {
327 file_id: scan.module.file_id,
328 counts_as_call: spec.counts_as_call,
329 site: UsageSite {
330 file: scan.file.clone(),
331 line,
332 col,
333 specifier: spec.specifier,
334 local_name: spec.local_name,
335 member: spec.member,
336 kind: spec.kind,
337 via: spec.via,
338 },
339 });
340 }
341
342 fn file_level_site(&mut self, scan: &ModuleScan<'_>, offset: u32, kind: UsageSiteKind) {
343 self.push_site(
344 scan,
345 SiteSpec {
346 offset,
347 specifier: None,
348 local_name: None,
349 member: None,
350 kind,
351 via: None,
352 counts_as_call: false,
353 },
354 );
355 }
356
357 fn note_binding(&mut self, file_id: FileId, specifier: &str, type_only: bool) {
358 let entry = self
359 .file_specifiers
360 .entry((file_id, specifier.to_owned()))
361 .or_default();
362 if type_only {
363 entry.type_binding = true;
364 } else {
365 entry.runtime_binding = true;
366 }
367 }
368
369 fn scan_module(&mut self, ctx: &UsageContext<'_>, module: &ModuleInfo) {
370 let Some(file) = ctx.file_path(module.file_id) else {
371 return;
372 };
373 let mut scan = ModuleScan {
374 module,
375 file,
376 bindings: FxHashMap::default(),
377 locals: FxHashMap::default(),
378 first_site: self.sites.len(),
379 };
380 self.scan_imports(ctx, &mut scan);
381 self.scan_calls(&scan);
382 let first_wrapper = self.wrappers.len();
383 self.scan_binding_references(&scan);
384 self.scan_module_wrapper_uses(&scan, first_wrapper);
385 self.scan_file_level_forms(ctx, &scan);
386 self.close_namespace_bindings(&scan);
387 }
388
389 fn scan_imports(&mut self, ctx: &UsageContext<'_>, scan: &mut ModuleScan<'_>) {
390 let file_id = scan.module.file_id;
391 for (index, import) in scan.module.imports.iter().enumerate() {
392 if !ctx.names_package(&import.source) {
393 continue;
394 }
395 self.attributed_files.insert(file_id);
396 let Ok(index) = u32::try_from(index) else {
397 break;
398 };
399 let name = match &import.imported_name {
400 ImportedName::SideEffect => {
401 self.file_level_site(scan, import.span.start, UsageSiteKind::SideEffectImport);
402 continue;
403 }
404 ImportedName::Named(name) => BindingName::Named(name.clone()),
405 ImportedName::Default => BindingName::Named(DEFAULT_SPECIFIER.to_owned()),
406 ImportedName::Namespace => BindingName::Namespace,
407 };
408 match &name {
409 BindingName::Named(specifier) => {
410 let type_only = import.is_type_only
411 || is_type_position_only(scan.module, &import.local_name);
412 self.note_binding(file_id, specifier, type_only);
413 }
414 BindingName::Namespace
415 if import.is_type_only
416 || is_type_position_only(scan.module, &import.local_name) =>
417 {
418 self.note_binding(file_id, NAMESPACE_SPECIFIER, true);
419 }
420 BindingName::Namespace => {}
421 }
422 if !import.is_type_only && !import.local_name.is_empty() {
423 scan.bindings.insert(index, name);
424 scan.locals.insert(import.local_name.as_str(), index);
425 }
426 }
427 }
428
429 fn binding_of<'s>(scan: &'s ModuleScan<'_>, local_name: &str) -> Option<&'s BindingName> {
431 scan.locals
432 .get(local_name)
433 .and_then(|index| scan.bindings.get(index))
434 }
435
436 fn scan_calls(&mut self, scan: &ModuleScan<'_>) {
437 let wrapper_calls = wrapper_initializer_calls(scan);
438 for call in scan.module.imported_call_sites.iter() {
439 let Some(binding) = Self::binding_of(scan, &call.local_name) else {
440 continue;
441 };
442 if wrapper_calls.contains(&call.span_start) {
444 continue;
445 }
446 let (specifier, member) = binding.split(&call.member_path);
447 self.note_namespace_use(scan, binding, &specifier);
448 self.push_site(
449 scan,
450 SiteSpec {
451 offset: call.span_start,
452 specifier: Some(specifier),
453 local_name: Some(call.local_name.clone()),
454 member,
455 kind: UsageSiteKind::Call,
456 via: None,
457 counts_as_call: false,
458 },
459 );
460 }
461 }
462
463 fn note_namespace_use(&mut self, scan: &ModuleScan<'_>, binding: &BindingName, name: &str) {
466 if matches!(binding, BindingName::Namespace) {
467 self.note_binding(scan.module.file_id, name, false);
468 }
469 }
470
471 fn scan_binding_references(&mut self, scan: &ModuleScan<'_>) {
472 for reference in direct_references(scan.module) {
473 let Some(binding) = scan.bindings.get(&reference.import_index) else {
474 continue;
475 };
476 let Some(import) = scan.module.imports.get(reference.import_index as usize) else {
477 continue;
478 };
479 let (specifier, member) = binding.split(&reference.member_path);
480 let wrapper = wrapper_shape(reference, member.as_deref()).and_then(|shape| {
481 let exports = wrapper_exports(scan.module, reference);
482 (!exports.is_empty()).then_some((shape, exports))
483 });
484 let kind = match (reference.kind, &wrapper) {
485 (_, Some(_)) => UsageSiteKind::WrapperDefinition,
486 (ImportBindingReferenceKind::InitializerCall, None) => continue,
488 (ImportBindingReferenceKind::ValueAlias, None) => UsageSiteKind::ValueAlias,
489 (ImportBindingReferenceKind::JsxElement, None) => UsageSiteKind::JsxElement,
490 (ImportBindingReferenceKind::Other | ImportBindingReferenceKind::Call, None) => {
491 UsageSiteKind::NonCallReference
492 }
493 };
494 let counts_as_call = matches!(&wrapper, Some((WrapperShape::Call, _)));
495 if let Some((shape, exports)) = wrapper {
496 let (line, _) = scan.position(reference.span_start);
497 let declared = reference.declared_name.as_deref().unwrap_or_default();
498 for (index, export) in exports.into_iter().enumerate() {
499 self.wrappers.push(Wrapper {
500 file_id: scan.module.file_id,
501 file: scan.file.clone(),
502 export,
503 declared: declared.to_owned(),
504 owns_module_uses: index == 0,
505 specifier: specifier.clone(),
506 shape,
507 line,
508 });
509 }
510 }
511 self.note_namespace_use(scan, binding, &specifier);
512 self.push_site(
513 scan,
514 SiteSpec {
515 offset: reference.span_start,
516 specifier: Some(specifier),
517 local_name: Some(import.local_name.clone()),
518 member,
519 kind,
520 via: None,
521 counts_as_call,
522 },
523 );
524 }
525 }
526
527 fn scan_module_wrapper_uses(&mut self, scan: &ModuleScan<'_>, first_wrapper: usize) {
529 let wrappers = self.wrappers.split_off(first_wrapper);
530 for wrapper in wrappers.iter().filter(|wrapper| wrapper.owns_module_uses) {
531 let declared = wrapper.declared.clone();
532 let uses = scan
533 .module
534 .import_binding_references
535 .iter()
536 .filter(|reference| reference.through.as_deref() == Some(declared.as_str()));
537 for reference in uses {
538 let member = non_empty(&reference.member_path);
539 let Some(kind) = via_site_kind(
540 wrapper.shape,
541 WrapperUse::Reference(reference.kind),
542 member.as_deref(),
543 exports_declared_value(scan.module, reference),
544 false,
545 ) else {
546 continue;
547 };
548 self.push_via_site(
549 scan,
550 wrapper,
551 ViaSite {
552 kind,
553 offset: reference.span_start,
554 local_name: &declared,
555 member,
556 },
557 );
558 }
559 }
560 self.wrappers.extend(wrappers);
561 }
562
563 fn push_via_site(&mut self, scan: &ModuleScan<'_>, wrapper: &Wrapper, site: ViaSite<'_>) {
565 let ViaSite {
566 kind,
567 offset,
568 local_name,
569 member,
570 } = site;
571 let (specifier, member) = if wrapper.specifier == NAMESPACE_SPECIFIER {
573 BindingName::Namespace.split(member.as_deref().unwrap_or_default())
574 } else {
575 (wrapper.specifier.clone(), member)
576 };
577 self.push_site(
578 scan,
579 SiteSpec {
580 offset,
581 specifier: Some(specifier),
582 local_name: Some(local_name.to_owned()),
583 member,
584 kind,
585 via: Some(wrapper.via()),
586 counts_as_call: false,
587 },
588 );
589 }
590
591 fn scan_file_level_forms(&mut self, ctx: &UsageContext<'_>, scan: &ModuleScan<'_>) {
592 let file_id = scan.module.file_id;
593 for re_export in &scan.module.re_exports {
594 if !ctx.names_package(&re_export.source) {
595 continue;
596 }
597 self.attributed_files.insert(file_id);
598 if re_export.imported_name == NAMESPACE_SPECIFIER {
599 self.file_level_site(scan, re_export.span.start, UsageSiteKind::StarReExport);
600 continue;
601 }
602 self.note_binding(file_id, &re_export.imported_name, re_export.is_type_only);
603 if re_export.is_type_only {
604 continue;
605 }
606 self.push_site(
607 scan,
608 SiteSpec {
609 offset: re_export.span.start,
610 specifier: Some(re_export.imported_name.clone()),
611 local_name: Some(re_export.imported_name.clone()),
612 member: None,
613 kind: UsageSiteKind::ReExport,
614 via: None,
615 counts_as_call: false,
616 },
617 );
618 }
619 for dynamic in &scan.module.dynamic_imports {
620 if dynamic.is_speculative || !ctx.names_package(&dynamic.source) {
621 continue;
622 }
623 self.attributed_files.insert(file_id);
624 self.file_level_site(scan, dynamic.span.start, UsageSiteKind::DynamicImport);
625 }
626 for require in &scan.module.require_calls {
627 if !ctx.names_package(&require.source) {
628 continue;
629 }
630 self.attributed_files.insert(file_id);
631 self.file_level_site(scan, require.span.start, UsageSiteKind::Require);
632 }
633 }
634
635 fn close_namespace_bindings(&mut self, scan: &ModuleScan<'_>) {
638 let file_id = scan.module.file_id;
639 for (index, binding) in &scan.bindings {
640 if !matches!(binding, BindingName::Namespace) {
641 continue;
642 }
643 let Some(import) = scan.module.imports.get(*index as usize) else {
644 continue;
645 };
646 if is_type_position_only(scan.module, &import.local_name) {
647 continue;
648 }
649 let used = self
650 .sites
651 .get(scan.first_site..)
652 .unwrap_or_default()
653 .iter()
654 .any(|raw| raw.site.local_name.as_deref() == Some(import.local_name.as_str()));
655 if !used {
656 self.note_binding(file_id, NAMESPACE_SPECIFIER, false);
657 }
658 }
659 }
660
661 fn follow_wrappers(&mut self, ctx: &UsageContext<'_>) {
663 let wrappers = std::mem::take(&mut self.wrappers);
664 let star_owners = star_re_export_owners(&wrappers);
665 for (index, wrapper) in wrappers.iter().enumerate() {
666 for (consumer_id, local_name, prefix) in wrapper_consumers(ctx.graph, wrapper) {
667 let Some(consumer) = ctx.modules_by_id.get(&consumer_id) else {
668 continue;
669 };
670 let Some(file) = ctx.file_path(consumer_id) else {
671 continue;
672 };
673 let scan = ModuleScan {
674 module: consumer,
675 file,
676 bindings: FxHashMap::default(),
677 locals: FxHashMap::default(),
678 first_site: self.sites.len(),
679 };
680 self.scan_consumer(&scan, wrapper, &local_name, prefix.as_deref());
681 }
682 self.scan_barrels(ctx, wrapper, star_owners.contains(&index));
683 }
684 self.wrappers = wrappers;
685 }
686
687 fn scan_barrels(&mut self, ctx: &UsageContext<'_>, wrapper: &Wrapper, owns_star: bool) {
692 let mut importers: Vec<FileId> = ctx.graph.importers_of(wrapper.file_id).to_vec();
693 importers.sort_unstable_by_key(|id| id.0);
694 importers.dedup();
695 for importer in importers {
696 let (Some(node), Some(module), Some(file)) = (
697 ctx.graph.modules.get(importer.0 as usize),
698 ctx.modules_by_id.get(&importer),
699 ctx.file_path(importer),
700 ) else {
701 continue;
702 };
703 let scan = ModuleScan {
704 module,
705 file,
706 bindings: FxHashMap::default(),
707 locals: FxHashMap::default(),
708 first_site: self.sites.len(),
709 };
710 for re_export in &node.re_exports {
711 let names_wrapper = re_export.imported_name == wrapper.export
712 || (re_export.imported_name == NAMESPACE_SPECIFIER && owns_star);
713 let written = !(re_export.span.start == 0 && re_export.span.end == 0);
716 if re_export.source_file != wrapper.file_id
717 || re_export.is_type_only
718 || !names_wrapper
719 || !written
720 {
721 continue;
722 }
723 self.push_site(
724 &scan,
725 SiteSpec {
726 offset: re_export.span.start,
727 specifier: Some(wrapper.specifier.clone()),
728 local_name: Some(wrapper.export.clone()),
729 member: None,
730 kind: UsageSiteKind::ReExport,
731 via: Some(wrapper.via()),
732 counts_as_call: false,
733 },
734 );
735 }
736 }
737 }
738
739 fn scan_consumer(
740 &mut self,
741 scan: &ModuleScan<'_>,
742 wrapper: &Wrapper,
743 local_name: &str,
744 prefix: Option<&str>,
745 ) {
746 let indexes: FxHashSet<u32> = scan
747 .module
748 .imports
749 .iter()
750 .enumerate()
751 .filter(|(_, import)| import.local_name == local_name && !import.is_type_only)
752 .filter_map(|(index, _)| u32::try_from(index).ok())
753 .collect();
754 let references: Vec<(&ImportBindingReference, Option<String>)> =
755 direct_references(scan.module)
756 .filter(|reference| indexes.contains(&reference.import_index))
757 .filter_map(
758 |reference| match match_member_prefix(&reference.member_path, prefix) {
759 PrefixMatch::Member(member) => Some((reference, member)),
760 PrefixMatch::Outside => None,
761 },
762 )
763 .collect();
764 let owned_calls: FxHashSet<u32> = references
767 .iter()
768 .filter(|(reference, member)| {
769 reference.kind == ImportBindingReferenceKind::InitializerCall
770 && via_site_kind(
771 wrapper.shape,
772 WrapperUse::Reference(reference.kind),
773 member.as_deref(),
774 exports_declared_value(scan.module, reference),
775 true,
776 )
777 .is_some()
778 })
779 .map(|(reference, _)| reference.span_start)
780 .collect();
781 for call in scan.module.imported_call_sites.iter() {
782 if call.local_name != local_name || owned_calls.contains(&call.span_start) {
783 continue;
784 }
785 let PrefixMatch::Member(member) = match_member_prefix(&call.member_path, prefix) else {
786 continue;
787 };
788 let Some(kind) = via_site_kind(
789 wrapper.shape,
790 WrapperUse::Call,
791 member.as_deref(),
792 false,
793 true,
794 ) else {
795 continue;
796 };
797 self.push_via_site(
798 scan,
799 wrapper,
800 ViaSite {
801 kind,
802 offset: call.span_start,
803 local_name,
804 member,
805 },
806 );
807 }
808 for (reference, member) in references {
809 let Some(kind) = via_site_kind(
810 wrapper.shape,
811 WrapperUse::Reference(reference.kind),
812 member.as_deref(),
813 exports_declared_value(scan.module, reference),
814 true,
815 ) else {
816 continue;
817 };
818 self.push_via_site(
819 scan,
820 wrapper,
821 ViaSite {
822 kind,
823 offset: reference.span_start,
824 local_name,
825 member,
826 },
827 );
828 }
829 }
830
831 fn wrapper_counts(&self) -> FxHashMap<(&str, String), WrapperCounts<'_>> {
836 let by_via: FxHashMap<String, &Wrapper> = self
837 .wrappers
838 .iter()
839 .map(|wrapper| (wrapper.via(), wrapper))
840 .collect();
841 let mut counts: FxHashMap<(&str, String), WrapperCounts<'_>> = FxHashMap::default();
842 for wrapper in &self.wrappers {
843 counts
844 .entry((wrapper.specifier.as_str(), wrapper.via()))
845 .or_default()
846 .wrapper = Some(wrapper);
847 }
848 for raw in &self.sites {
849 let (Some(name), Some(via)) = (raw.site.specifier.as_deref(), &raw.site.via) else {
850 continue;
851 };
852 let entry = counts.entry((name, via.clone())).or_default();
853 entry.wrapper = by_via.get(via).copied();
854 if raw.site.kind == UsageSiteKind::Call {
855 entry.calls += 1;
856 entry.files.insert(raw.file_id);
857 }
858 }
859 counts
860 }
861
862 fn file_level_unresolved(&self) -> FileLevelUnresolved {
863 let mut unresolved = FileLevelUnresolved::default();
864 for raw in &self.sites {
865 match raw.site.kind {
866 UsageSiteKind::DynamicImport => unresolved.dynamic_import += 1,
867 UsageSiteKind::Require => unresolved.require += 1,
868 UsageSiteKind::SideEffectImport => unresolved.side_effect_import += 1,
869 UsageSiteKind::StarReExport => unresolved.star_re_export += 1,
870 _ => {}
871 }
872 }
873 unresolved
874 }
875
876 fn specifier_usages(&self, selected: Option<&[String]>) -> Vec<SpecifierUsage> {
877 let mut usages: FxHashMap<String, SpecifierUsage> = FxHashMap::default();
878 let entry = |usages: &mut FxHashMap<String, SpecifierUsage>, name: &str| {
879 if usages.contains_key(name) {
880 return;
881 }
882 usages.insert(name.to_owned(), empty_specifier_usage(name));
883 };
884 if let Some(selected) = selected {
885 for name in selected {
886 entry(&mut usages, name);
887 }
888 }
889 let wanted = |name: &str| selected.is_none_or(|names| names.iter().any(|n| n == name));
890 for ((file_id, name), state) in &self.file_specifiers {
891 if !wanted(name) {
892 continue;
893 }
894 entry(&mut usages, name);
895 let Some(usage) = usages.get_mut(name) else {
896 continue;
897 };
898 usage.file_count += 1;
899 if state.type_binding && !state.runtime_binding {
900 usage.type_only_file_count += 1;
901 }
902 if state.runtime_binding && !self.sited.contains(&(*file_id, name.clone())) {
903 usage.unresolved.binding_without_site += 1;
904 }
905 }
906 for raw in &self.sites {
907 let Some(name) = raw.site.specifier.as_deref() else {
908 continue;
909 };
910 if !wanted(name) {
911 continue;
912 }
913 entry(&mut usages, name);
914 if let Some(usage) = usages.get_mut(name) {
915 count_site(usage, raw);
916 }
917 }
918 for ((name, _), counts) in self.wrapper_counts() {
919 let (Some(usage), Some(wrapper)) = (usages.get_mut(name), counts.wrapper) else {
920 continue;
921 };
922 usage.wrappers.push(UsageWrapper {
923 file: wrapper.file.clone(),
924 export: wrapper.export.clone(),
925 shape: wrapper.shape,
926 line: wrapper.line,
927 consumer_file_count: counts.files.len(),
928 call_site_count: counts.calls,
929 });
930 }
931 let mut usages: Vec<SpecifierUsage> = usages.into_values().collect();
932 for usage in &mut usages {
933 usage
934 .wrappers
935 .sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.export.cmp(&b.export)));
936 }
937 usages.sort_by(|a, b| a.name.cmp(&b.name));
938 usages
939 }
940}
941
942fn is_type_position_only(module: &ModuleInfo, local_name: &str) -> bool {
948 let has = |names: &[String]| names.iter().any(|name| name == local_name);
949 has(&module.type_referenced_import_bindings) && !has(&module.value_referenced_import_bindings)
950}
951
952fn empty_specifier_usage(name: &str) -> SpecifierUsage {
953 SpecifierUsage {
954 name: name.to_owned(),
955 file_count: 0,
956 type_only_file_count: 0,
957 call_site_count: 0,
958 unresolved: SpecifierUnresolved::default(),
959 wrappers: Vec::new(),
960 }
961}
962
963fn count_site(usage: &mut SpecifierUsage, raw: &RawSite) {
964 let site = &raw.site;
965 let unresolved = &mut usage.unresolved;
966 match site.kind {
967 UsageSiteKind::Call if site.via.is_none() => usage.call_site_count += 1,
968 UsageSiteKind::WrapperDefinition if raw.counts_as_call => usage.call_site_count += 1,
969 UsageSiteKind::ValueAlias => unresolved.value_alias += 1,
970 UsageSiteKind::NonCallReference => unresolved.non_call_reference += 1,
971 UsageSiteKind::JsxElement => unresolved.jsx_element += 1,
972 UsageSiteKind::ReExport => unresolved.re_export += 1,
973 UsageSiteKind::NestedWrapper => unresolved.nested_wrapper += 1,
974 _ => {}
975 }
976}
977
978fn star_re_export_owners(wrappers: &[Wrapper]) -> FxHashSet<usize> {
982 let mut seen: FxHashSet<(FileId, &str)> = FxHashSet::default();
983 wrappers
984 .iter()
985 .enumerate()
986 .filter(|(_, wrapper)| wrapper.export != DEFAULT_SPECIFIER)
987 .filter(|(_, wrapper)| seen.insert((wrapper.file_id, wrapper.declared.as_str())))
988 .map(|(index, _)| index)
989 .collect()
990}
991
992fn wrapper_initializer_calls(scan: &ModuleScan<'_>) -> FxHashSet<u32> {
994 direct_references(scan.module)
995 .filter(|reference| {
996 reference.kind == ImportBindingReferenceKind::InitializerCall
997 && scan.bindings.contains_key(&reference.import_index)
998 && exports_declared_value(scan.module, reference)
999 })
1000 .map(|reference| reference.span_start)
1001 .collect()
1002}
1003
1004const fn wrapper_shape(
1007 reference: &ImportBindingReference,
1008 member: Option<&str>,
1009) -> Option<WrapperShape> {
1010 match reference.kind {
1011 ImportBindingReferenceKind::InitializerCall => Some(WrapperShape::Call),
1012 ImportBindingReferenceKind::ValueAlias if member.is_none() => Some(WrapperShape::Alias),
1013 _ => None,
1014 }
1015}
1016
1017fn direct_references(module: &ModuleInfo) -> impl Iterator<Item = &ImportBindingReference> {
1020 module
1021 .import_binding_references
1022 .iter()
1023 .filter(|reference| reference.through.is_none())
1024}
1025
1026fn wrapper_exports(module: &ModuleInfo, reference: &ImportBindingReference) -> Vec<String> {
1030 let Some(declared) = reference.declared_name.as_deref() else {
1031 return Vec::new();
1032 };
1033 module
1034 .exports
1035 .iter()
1036 .filter(|export| {
1037 !export.is_type_only
1038 && match &export.local_name {
1039 Some(local) => local == declared,
1040 None => export.name.matches_str(declared),
1041 }
1042 })
1043 .map(|export| match &export.name {
1044 ExportName::Named(name) => name.clone(),
1045 ExportName::Default => DEFAULT_SPECIFIER.to_owned(),
1046 })
1047 .collect()
1048}
1049
1050fn exports_declared_value(module: &ModuleInfo, reference: &ImportBindingReference) -> bool {
1051 !wrapper_exports(module, reference).is_empty()
1052}
1053
1054fn via_site_kind(
1062 shape: WrapperShape,
1063 wrapper_use: WrapperUse,
1064 member: Option<&str>,
1065 exported: bool,
1066 has_call_entry: bool,
1067) -> Option<UsageSiteKind> {
1068 let reads_result_member = shape == WrapperShape::Call && member.is_some();
1069 let call_kind = if reads_result_member {
1070 UsageSiteKind::NonCallReference
1071 } else {
1072 UsageSiteKind::Call
1073 };
1074 let kind = match wrapper_use {
1075 WrapperUse::Call | WrapperUse::Reference(ImportBindingReferenceKind::Call) => call_kind,
1076 WrapperUse::Reference(ImportBindingReferenceKind::InitializerCall) => {
1077 if exported && !reads_result_member {
1078 UsageSiteKind::NestedWrapper
1079 } else if has_call_entry {
1080 return None;
1081 } else {
1082 call_kind
1083 }
1084 }
1085 WrapperUse::Reference(ImportBindingReferenceKind::ValueAlias) => {
1086 if exported && !reads_result_member {
1087 UsageSiteKind::NestedWrapper
1088 } else {
1089 UsageSiteKind::ValueAlias
1090 }
1091 }
1092 WrapperUse::Reference(ImportBindingReferenceKind::JsxElement) => UsageSiteKind::JsxElement,
1093 WrapperUse::Reference(ImportBindingReferenceKind::Other) => UsageSiteKind::NonCallReference,
1094 };
1095 Some(kind)
1096}
1097
1098enum PrefixMatch {
1100 Outside,
1102 Member(Option<String>),
1104}
1105
1106fn match_member_prefix(member_path: &str, prefix: Option<&str>) -> PrefixMatch {
1108 let Some(prefix) = prefix else {
1109 return PrefixMatch::Member(non_empty(member_path));
1110 };
1111 if member_path == prefix {
1112 return PrefixMatch::Member(None);
1113 }
1114 member_path
1115 .strip_prefix(prefix)
1116 .and_then(|rest| rest.strip_prefix('.'))
1117 .map_or(PrefixMatch::Outside, |rest| {
1118 PrefixMatch::Member(non_empty(rest))
1119 })
1120}
1121
1122fn wrapper_consumers(
1125 graph: &ModuleGraph,
1126 wrapper: &Wrapper,
1127) -> Vec<(FileId, String, Option<String>)> {
1128 let mut consumers = Vec::new();
1129 let mut seen: FxHashSet<(FileId, &str)> = FxHashSet::default();
1130 for &importer in graph.importers_of(wrapper.file_id) {
1131 for (target, symbols) in graph.outgoing_symbol_edges(importer) {
1132 if target != wrapper.file_id {
1133 continue;
1134 }
1135 for symbol in symbols {
1136 if symbol.is_type_only {
1137 continue;
1138 }
1139 let prefix = match &symbol.imported_name {
1140 ImportedName::Named(name) if *name == wrapper.export => None,
1141 ImportedName::Default if wrapper.export == DEFAULT_SPECIFIER => None,
1142 ImportedName::Namespace => Some(wrapper.export.clone()),
1143 _ => continue,
1144 };
1145 if seen.insert((importer, symbol.local_name.as_str())) {
1146 consumers.push((importer, symbol.local_name.clone(), prefix));
1147 }
1148 }
1149 }
1150 }
1151 consumers
1152}
1153
1154type SiteKey<'a> = (
1158 &'a str,
1159 u32,
1160 u32,
1161 UsageSiteKind,
1162 &'a str,
1163 &'a str,
1164 &'a str,
1165 &'a str,
1166);
1167
1168fn site_key(site: &UsageSite) -> SiteKey<'_> {
1169 (
1170 site.file.as_str(),
1171 site.line,
1172 site.col,
1173 site.kind,
1174 site.specifier.as_deref().unwrap_or(""),
1175 site.via.as_deref().unwrap_or(""),
1176 site.local_name.as_deref().unwrap_or(""),
1177 site.member.as_deref().unwrap_or(""),
1178 )
1179}
1180
1181struct CursorKey {
1183 file: String,
1184 line: u32,
1185 col: u32,
1186 kind: UsageSiteKind,
1187 specifier: String,
1188 via: String,
1189 local_name: String,
1190 member: String,
1191}
1192
1193impl CursorKey {
1194 fn key(&self) -> SiteKey<'_> {
1195 (
1196 self.file.as_str(),
1197 self.line,
1198 self.col,
1199 self.kind,
1200 self.specifier.as_str(),
1201 self.via.as_str(),
1202 self.local_name.as_str(),
1203 self.member.as_str(),
1204 )
1205 }
1206}
1207
1208fn site_page(
1209 sites: &[RawSite],
1210 selected: Option<&[String]>,
1211 package_name: &str,
1212 limit: u16,
1213 cursor: Option<&str>,
1214) -> Result<UsageSitePage, UsageError> {
1215 let hash = query_hash(package_name, selected.unwrap_or(&[]));
1216 let mut matching: Vec<&UsageSite> = sites
1217 .iter()
1218 .map(|raw| &raw.site)
1219 .filter(|site| match selected {
1220 None => true,
1221 Some(names) => site
1222 .specifier
1223 .as_deref()
1224 .is_some_and(|name| names.iter().any(|n| n == name)),
1225 })
1226 .collect();
1227 matching.sort_by(|a, b| site_key(a).cmp(&site_key(b)));
1228 let total = matching.len();
1229 let start = match cursor {
1230 Some(token) => {
1231 let after = decode_cursor(token, hash)?;
1232 let after = after.key();
1233 matching.partition_point(|site| site_key(site) <= after)
1234 }
1235 None => 0,
1236 };
1237 let end = start.saturating_add(usize::from(limit)).min(total);
1238 let items: Vec<UsageSite> = matching
1239 .get(start..end)
1240 .unwrap_or_default()
1241 .iter()
1242 .map(|site| (*site).clone())
1243 .collect();
1244 let next_cursor = (end < total)
1245 .then(|| items.last().map(|last| encode_cursor(hash, last)))
1246 .flatten();
1247 Ok(UsageSitePage {
1248 items,
1249 total,
1250 limit,
1251 next_cursor,
1252 })
1253}
1254
1255fn query_hash(package_name: &str, selected: &[String]) -> u64 {
1257 let mut hash = FNV_OFFSET_BASIS;
1258 let mut feed = |bytes: &[u8]| {
1259 for byte in bytes {
1260 hash ^= u64::from(*byte);
1261 hash = hash.wrapping_mul(FNV_PRIME);
1262 }
1263 };
1264 feed(package_name.as_bytes());
1265 for name in selected {
1266 feed(&[0]);
1267 feed(name.as_bytes());
1268 }
1269 hash
1270}
1271
1272fn encode_cursor(hash: u64, site: &UsageSite) -> String {
1273 let payload = [
1274 format!("{hash:016x}"),
1275 site.file.clone(),
1276 site.line.to_string(),
1277 site.col.to_string(),
1278 site.kind.as_str().to_owned(),
1279 site.specifier.clone().unwrap_or_default(),
1280 site.via.clone().unwrap_or_default(),
1281 site.local_name.clone().unwrap_or_default(),
1282 site.member.clone().unwrap_or_default(),
1283 ]
1284 .join(&CURSOR_SEPARATOR.to_string());
1285 let mut token = String::with_capacity(CURSOR_PREFIX.len() + payload.len() * 2);
1286 token.push_str(CURSOR_PREFIX);
1287 for byte in payload.as_bytes() {
1288 let _ = write!(token, "{byte:02x}");
1289 }
1290 token
1291}
1292
1293fn decode_cursor(token: &str, expected_hash: u64) -> Result<CursorKey, UsageError> {
1294 let hex = token
1295 .strip_prefix(CURSOR_PREFIX)
1296 .ok_or(UsageError::InvalidCursor)?;
1297 if hex.len() % 2 != 0 {
1298 return Err(UsageError::InvalidCursor);
1299 }
1300 let bytes = (0..hex.len())
1301 .step_by(2)
1302 .map(|index| {
1303 hex.get(index..index + 2)
1304 .and_then(|pair| u8::from_str_radix(pair, 16).ok())
1305 })
1306 .collect::<Option<Vec<u8>>>()
1307 .ok_or(UsageError::InvalidCursor)?;
1308 let payload = String::from_utf8(bytes).map_err(|_| UsageError::InvalidCursor)?;
1309 let fields: Vec<&str> = payload.split(CURSOR_SEPARATOR).collect();
1310 let [
1311 hash,
1312 file,
1313 line,
1314 col,
1315 kind,
1316 specifier,
1317 via,
1318 local_name,
1319 member,
1320 ] = fields.as_slice()
1321 else {
1322 return Err(UsageError::InvalidCursor);
1323 };
1324 if u64::from_str_radix(hash, 16).ok() != Some(expected_hash) {
1325 return Err(UsageError::InvalidCursor);
1326 }
1327 Ok(CursorKey {
1328 file: (*file).to_owned(),
1329 line: line.parse().map_err(|_| UsageError::InvalidCursor)?,
1330 col: col.parse().map_err(|_| UsageError::InvalidCursor)?,
1331 kind: site_kind_from_str(kind).ok_or(UsageError::InvalidCursor)?,
1332 specifier: (*specifier).to_owned(),
1333 via: (*via).to_owned(),
1334 local_name: (*local_name).to_owned(),
1335 member: (*member).to_owned(),
1336 })
1337}
1338
1339fn site_kind_from_str(value: &str) -> Option<UsageSiteKind> {
1340 [
1341 UsageSiteKind::Call,
1342 UsageSiteKind::WrapperDefinition,
1343 UsageSiteKind::ValueAlias,
1344 UsageSiteKind::NonCallReference,
1345 UsageSiteKind::JsxElement,
1346 UsageSiteKind::ReExport,
1347 UsageSiteKind::NestedWrapper,
1348 UsageSiteKind::DynamicImport,
1349 UsageSiteKind::Require,
1350 UsageSiteKind::SideEffectImport,
1351 UsageSiteKind::StarReExport,
1352 ]
1353 .into_iter()
1354 .find(|kind| kind.as_str() == value)
1355}
1356
1357fn closure_seeds(
1360 ctx: &UsageContext<'_>,
1361 scan: &Scan,
1362 imported_by: &[PathBuf],
1363 selected: Option<&[String]>,
1364) -> FxHashSet<FileId> {
1365 match selected {
1366 None => imported_by
1367 .iter()
1368 .filter_map(|path| ctx.file_id(&path_key(path)))
1369 .collect(),
1370 Some(names) => scan
1371 .sites
1372 .iter()
1373 .filter(|raw| {
1374 raw.site
1375 .specifier
1376 .as_deref()
1377 .is_some_and(|name| names.iter().any(|n| n == name))
1378 })
1379 .map(|raw| raw.file_id)
1380 .collect(),
1381 }
1382}
1383
1384fn consumer_closure(
1387 graph: &ModuleGraph,
1388 root: &Path,
1389 seeds: &FxHashSet<FileId>,
1390 depth: u32,
1391) -> ConsumerClosure {
1392 let mut visited = seeds.clone();
1393 let mut frontier: Vec<FileId> = seeds.iter().copied().collect();
1394 frontier.sort_unstable_by_key(|id| id.0);
1395 let mut found: Vec<(FileId, u32)> = Vec::new();
1396 let mut reached = 0;
1397 for level in 1..=depth {
1398 let mut next = Vec::new();
1399 for file in &frontier {
1400 for &importer in graph.importers_of(*file) {
1401 if visited.insert(importer) {
1402 next.push(importer);
1403 found.push((importer, level));
1404 }
1405 }
1406 }
1407 frontier = next;
1408 if frontier.is_empty() {
1409 break;
1410 }
1411 reached = level;
1412 }
1413 let truncated = reached == depth
1414 && frontier.iter().any(|file| {
1415 graph
1416 .importers_of(*file)
1417 .iter()
1418 .any(|importer| !visited.contains(importer))
1419 });
1420 let mut files: Vec<ClosureFile> = found
1421 .into_iter()
1422 .filter_map(|(id, level)| {
1423 graph.modules.get(id.0 as usize).map(|node| ClosureFile {
1424 file: relativize(&node.path, root),
1425 depth: level,
1426 })
1427 })
1428 .collect();
1429 files.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.file.cmp(&b.file)));
1430 ConsumerClosure {
1431 depth,
1432 file_count: files.len(),
1433 files,
1434 truncated,
1435 }
1436}
1437
1438#[cfg(test)]
1439#[path = "trace_usage_impl_tests.rs"]
1440mod tests;