1use std::collections::{HashMap, HashSet, VecDeque};
7use std::fmt::Write as _;
8use std::path::Path;
9
10use serde::Serialize;
11use skif_js_syntax::{ExportKind, Import, ImportKind, ImportedKind, ImportedName};
12use skif_workspace::PackageId;
13
14use crate::analysis::JsAnalysis;
15use crate::discover::relative;
16use crate::globs::Globs;
17use crate::graph::{Module, ModuleId, Target};
18use crate::input::IncompleteEntries;
19use crate::manifest_paths::{crossing_stars, manifest_targets, source_candidates, source_patterns};
20use crate::resolve::join_normalized;
21use crate::setup::PackageSetup;
22
23const MAX_DEPTH: usize = 8;
25const MAX_LISTED: usize = 12;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31#[non_exhaustive]
32pub enum EntryReason {
33 EntryGlob,
35 ConfigFile,
37 Script,
39 Published,
41 ExternalEntryGlob,
43 IncompleteScope,
46 Unknown,
48}
49
50impl EntryReason {
51 const fn describe(self) -> &'static str {
52 match self {
53 Self::EntryGlob => {
54 "matches an entry glob of its package (defaults, configuration, test conventions or a plugin)"
55 }
56 Self::ConfigFile => "a configuration file of a tool",
57 Self::Script => "run by a script of package.json",
58 Self::Published => {
59 "published by package.json (main, module, types, bin, exports, n8n nodes and credentials)"
60 }
61 Self::ExternalEntryGlob => "matches an entry glob of another package that points outside it",
62 Self::IncompleteScope => {
63 "may be an entry of a configuration that is not fully known, so it is treated as one (narrowed scope)"
64 }
65 Self::Unknown => "unknown: no entry rule of the analysis accounts for it",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73#[non_exhaustive]
74pub enum Absence {
75 UnusedFile,
77 PackageExcluded,
79 DeclarationFile,
81 EntriesIncomplete,
84 OutsideProject,
86 Ignored,
88 NotDiscovered,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "kebab-case")]
96#[non_exhaustive]
97pub struct Importer {
98 pub file: String,
100 pub line: u32,
102 pub kind: ImportKind,
104 pub names: Vec<String>,
106 pub reachable: bool,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
112#[serde(rename_all = "kebab-case")]
113#[non_exhaustive]
114pub struct ExportUser {
115 pub file: String,
117 pub line: u32,
119 pub how: String,
121 pub reachable: bool,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
127#[serde(rename_all = "kebab-case")]
128#[non_exhaustive]
129pub enum Verdict {
130 NotAnExport,
132 Public,
134 Used,
136 Unused,
138 NotJudged,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "kebab-case")]
146#[non_exhaustive]
147pub struct ReExport {
148 pub file: String,
150 pub name: String,
152 pub export: ExportReport,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[allow(
159 clippy::struct_excessive_bools,
160 reason = "independent facts of one serialized report"
161)]
162#[serde(rename_all = "kebab-case")]
163#[non_exhaustive]
164pub struct ExportReport {
165 pub name: String,
167 pub file: String,
169 pub verdict: Verdict,
171 pub entry_public: bool,
173 pub tagged_public: bool,
175 pub users: Vec<ExportUser>,
177 pub re_exports: Vec<ReExport>,
179 pub in_used_signature: bool,
181 pub is_re_export: bool,
183 #[serde(skip_serializing_if = "is_false")]
185 pub registered: bool,
186 #[serde(skip_serializing_if = "is_false")]
188 pub entries_incomplete: bool,
189 #[serde(skip_serializing_if = "is_false")]
191 pub already_explained: bool,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
196#[serde(rename_all = "kebab-case")]
197#[non_exhaustive]
198pub struct UsageReport {
199 pub file: String,
201 pub in_graph: bool,
203 pub absence: Option<Absence>,
205 #[serde(skip_serializing_if = "Vec::is_empty")]
209 pub incomplete: Vec<IncompleteEntries>,
210 #[serde(skip_serializing_if = "Vec::is_empty")]
215 pub incomplete_scope: Vec<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
220 pub widened_by: Option<String>,
221 pub entry: Option<EntryReason>,
223 pub imported_by: Vec<Importer>,
225 pub chain_from_entry: Option<Vec<String>>,
227 pub export: Option<ExportReport>,
229}
230
231impl UsageReport {
232 fn render_absence(&self, absence: Absence, out: &mut String) {
234 match absence {
235 Absence::UnusedFile => {
236 let _ = writeln!(out, " no entry file reaches it: reported by `unused-files`");
237 if !self.incomplete_scope.is_empty() {
238 let _ = writeln!(
239 out,
240 " the entry files of its package are not fully known, but the unknown ones can only match {} \
241 (narrowed scope); this file does not, so it is judged",
242 self.incomplete_scope.join(", ")
243 );
244 self.render_incomplete(out);
245 }
246 }
247 Absence::PackageExcluded => {
248 let _ = writeln!(out, " not analysed: its package is excluded from the run");
249 }
250 Absence::DeclarationFile => {
251 let _ = writeln!(
252 out,
253 " not in the module graph: a declaration file is never reported as unused"
254 );
255 }
256 Absence::EntriesIncomplete => {
257 let _ = writeln!(
258 out,
259 " not in the module graph, and not reported as unused: the entry files of its package are \
260 not fully known and may be anywhere in it (package-wide)"
261 );
262 if let Some(at) = &self.widened_by {
263 let _ = writeln!(
264 out,
265 " {at} imports a computed path, which may load any file of the package, so the narrowed \
266 scope of its configs is not used"
267 );
268 }
269 self.render_incomplete(out);
270 }
271 Absence::OutsideProject => {
272 let _ = writeln!(out, " not analysed: outside the project globs of its package");
273 }
274 Absence::Ignored => {
275 let _ = writeln!(out, " not analysed: matched by an `ignore` glob");
276 }
277 Absence::NotDiscovered => {
278 let _ = writeln!(
279 out,
280 " not analysed: missing, ignored by Git, or not a source file the analysis looks at"
281 );
282 }
283 }
284 }
285
286 fn render_incomplete(&self, out: &mut String) {
288 for incomplete in &self.incomplete {
289 let source = match (&incomplete.plugin, &incomplete.file) {
290 (Some(plugin), Some(file)) => format!("{plugin} reads {}: ", slashed(file)),
291 (None, Some(file)) => format!("{}: ", slashed(file)),
292 (Some(plugin), None) => format!("{plugin}: "),
293 (None, None) => String::new(),
294 };
295 let _ = writeln!(out, " {source}{}", incomplete.cause.describe());
296 }
297 }
298
299 #[must_use]
301 pub fn render(&self) -> String {
302 let mut out = String::new();
303 let _ = writeln!(out, "{}", self.file);
304 if let Some(absence) = self.absence {
305 self.render_absence(absence, &mut out);
306 return out;
307 }
308 if let Some(entry) = self.entry {
309 let _ = writeln!(out, " entry file: {}", entry.describe());
310 if entry == EntryReason::IncompleteScope {
311 let _ = writeln!(
312 out,
313 " the narrowed scope of its package: {}",
314 self.incomplete_scope.join(", ")
315 );
316 self.render_incomplete(&mut out);
317 }
318 }
319 if self.imported_by.is_empty() {
320 let _ = writeln!(out, " imported by no module");
321 } else {
322 let _ = writeln!(out, " imported by {} place(s):", self.imported_by.len());
323 for importer in self.imported_by.iter().take(MAX_LISTED) {
324 let _ = writeln!(
325 out,
326 " {}:{} ({}: {})",
327 importer.file,
328 importer.line,
329 kind_label(importer.kind),
330 names_label(&importer.names)
331 );
332 }
333 if self.imported_by.len() > MAX_LISTED {
334 let _ = writeln!(out, " ... and {} more", self.imported_by.len() - MAX_LISTED);
335 }
336 }
337 match &self.chain_from_entry {
338 Some(chain) => {
339 let _ = writeln!(out, " reached from an entry through: {}", chain.join(" -> "));
340 }
341 None => {
342 let _ = writeln!(out, " no chain of imports from an entry file reaches it");
343 }
344 }
345 if let Some(export) = &self.export {
346 render_export(export, 0, &mut out);
347 }
348 out
349 }
350}
351
352const fn kind_label(kind: ImportKind) -> &'static str {
354 match kind {
355 ImportKind::Static => "static",
356 ImportKind::TypeOnly => "type-only",
357 ImportKind::SideEffect => "side-effect",
358 ImportKind::Dynamic => "dynamic",
359 ImportKind::Require => "require",
360 ImportKind::ImportMetaGlob => "import-meta-glob",
361 ImportKind::SourceGlob => "source-glob",
362 ImportKind::ExportFrom => "export-from",
363 ImportKind::Mock => "mock",
364 }
365}
366
367fn names_label(names: &[String]) -> String {
368 if names.is_empty() {
369 "the whole module".to_owned()
370 } else {
371 names.join(", ")
372 }
373}
374
375fn render_export(export: &ExportReport, depth: usize, out: &mut String) {
376 let pad = " ".repeat(depth + 1);
377 if export.verdict == Verdict::NotAnExport {
378 let _ = writeln!(out, "{pad}`{}` is not an export of {}", export.name, export.file);
379 return;
380 }
381 if export.already_explained {
382 let _ = writeln!(
383 out,
384 "{pad}`{}` of {} is already explained above",
385 export.name, export.file
386 );
387 return;
388 }
389 let _ = writeln!(out, "{pad}export `{}` of {}:", export.name, export.file);
390 if export.entry_public {
391 let _ = writeln!(out, "{pad} the file is an entry, so its exports are public");
392 }
393 if export.tagged_public {
394 let _ = writeln!(out, "{pad} tagged `@public` (or another ignored tag)");
395 }
396 for user in &export.users {
397 let unreached = if user.reachable {
398 ""
399 } else {
400 "; no entry file reaches the importer"
401 };
402 let _ = writeln!(
403 out,
404 "{pad} imported by {}:{} ({}{unreached})",
405 user.file, user.line, user.how
406 );
407 }
408 for re_export in &export.re_exports {
409 let _ = writeln!(out, "{pad} re-exported by {} as `{}`:", re_export.file, re_export.name);
410 render_export(&re_export.export, depth + 1, out);
411 }
412 if export.users.is_empty() && export.re_exports.is_empty() {
413 let _ = writeln!(out, "{pad} no module imports it by name or as a whole");
414 }
415 if export.in_used_signature {
416 let _ = writeln!(
417 out,
418 "{pad} mentioned in the signature of a used export of the same file"
419 );
420 }
421 if export.is_re_export {
422 let _ = writeln!(out, "{pad} itself a re-export of another module");
423 }
424 if export.registered {
425 let _ = writeln!(out, "{pad} registered as a custom element");
426 }
427 if export.entries_incomplete {
428 let _ = writeln!(
429 out,
430 "{pad} the entry files of its package are not fully known, so its exports are not judged"
431 );
432 }
433 let verdict = match export.verdict {
434 Verdict::Public => "public",
435 Verdict::Used => "used by another module",
436 Verdict::NotJudged => "not judged: no known module uses it, but its package's entries are incomplete",
437 _ => "not used by another module",
438 };
439 let _ = writeln!(out, "{pad}=> {verdict}");
440}
441
442fn describe_name(name: &ImportedName) -> String {
443 match &name.kind {
444 ImportedKind::Default => "default".to_owned(),
445 ImportedKind::Named(export) => export.clone(),
446 ImportedKind::Namespace if name.escapes => "the namespace, used as a whole".to_owned(),
447 ImportedKind::Namespace => format!("the namespace, members {}", name.accessed_members.join(", ")),
448 }
449}
450
451fn reached(target: &Target) -> &[ModuleId] {
453 match target {
454 Target::Module(id) => std::slice::from_ref(id),
455 Target::Modules(ids) => ids.as_slice(),
456 _ => &[],
457 }
458}
459
460fn uses_export(import: &Import, export: &str) -> Option<String> {
462 if matches!(
463 import.kind,
464 ImportKind::ExportFrom | ImportKind::SideEffect | ImportKind::Mock
465 ) {
466 return None;
467 }
468 if import.names.is_empty() {
469 return Some("the whole module".to_owned());
470 }
471 import.names.iter().find_map(|name| {
472 let hit = match &name.kind {
473 ImportedKind::Default => export == "default",
474 ImportedKind::Named(imported) => imported == export,
475 ImportedKind::Namespace => name.escapes || name.accessed_members.iter().any(|member| member == export),
476 };
477 hit.then(|| describe_name(name))
478 })
479}
480
481struct Walk {
483 reachable: HashSet<ModuleId>,
484 seen: HashSet<(ModuleId, String)>,
485}
486
487#[allow(clippy::trivially_copy_pass_by_ref)]
488fn is_false(value: &bool) -> bool {
489 !*value
490}
491
492fn slashed(path: &Path) -> String {
493 path.to_string_lossy().replace('\\', "/")
494}
495
496impl JsAnalysis<'_> {
497 #[must_use]
500 pub fn explain_usage(&self, file: &Path, export: Option<&str>) -> UsageReport {
501 let Some(module) = self
502 .graph
503 .modules()
504 .iter()
505 .find(|module| module.path == file || module.abs == file)
506 else {
507 let unused = self.unused_files.iter().find(|unused| unused.path == file);
508 let (absence, incomplete, incomplete_scope) = if let Some(unused) = unused {
509 let (incomplete, scope) = self.narrowed_scope(unused.owner);
510 (Absence::UnusedFile, incomplete, scope)
511 } else {
512 let (absence, incomplete) = self.absence_of(file);
513 (absence, incomplete, Vec::new())
514 };
515 let widened_by = (absence == Absence::EntriesIncomplete)
516 .then(|| self.widened_by(file))
517 .flatten();
518 return UsageReport {
519 file: slashed(file),
520 in_graph: false,
521 absence: Some(absence),
522 incomplete,
523 incomplete_scope,
524 widened_by,
525 entry: None,
526 imported_by: Vec::new(),
527 chain_from_entry: None,
528 export: None,
529 };
530 };
531 let mut walk = Walk {
532 reachable: self.reachable_from_entries(),
533 seen: HashSet::new(),
534 };
535 let chain = self
536 .chain_to_entry(module.id)
537 .map(|chain| chain.iter().map(|id| slashed(&self.graph.module(*id).path)).collect());
538 let entry = module.is_entry.then(|| self.entry_reason(module));
539 let (incomplete, incomplete_scope) = if entry == Some(EntryReason::IncompleteScope) {
540 self.narrowed_scope(module.owner)
541 } else {
542 (Vec::new(), Vec::new())
543 };
544 UsageReport {
545 file: slashed(&module.path),
546 in_graph: true,
547 absence: None,
548 incomplete,
549 incomplete_scope,
550 widened_by: None,
551 entry,
552 imported_by: self.importers(module, &walk),
553 chain_from_entry: chain,
554 export: export.map(|name| self.explain_export(module, name, 0, &mut walk)),
555 }
556 }
557
558 pub(crate) fn reachable_from_entries(&self) -> HashSet<ModuleId> {
559 let modules = self.graph.modules();
560 let mut queue: VecDeque<ModuleId> = modules
561 .iter()
562 .filter(|module| module.is_entry)
563 .map(|module| module.id)
564 .collect();
565 let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
566 while let Some(current) = queue.pop_front() {
567 for resolved in &modules[current.index()].imports {
568 for next in reached(&resolved.target) {
569 if seen.insert(*next) {
570 queue.push_back(*next);
571 }
572 }
573 }
574 }
575 seen
576 }
577
578 fn entry_reason(&self, module: &Module) -> EntryReason {
580 if let Some(package) = self.setup.package(module.owner)
581 && let Some(rel) = relative(&module.abs, &package.dir)
582 {
583 if package.entry.is_match(&rel) {
584 return EntryReason::EntryGlob;
585 }
586 if package.config_files.contains(&module.abs) {
587 return EntryReason::ConfigFile;
588 }
589 if self.is_script_file(&module.abs) {
590 return EntryReason::Script;
591 }
592 if self.is_published(module, &package.dir, &rel) {
593 return EntryReason::Published;
594 }
595 if package.in_incomplete_scope(&rel) {
596 return EntryReason::IncompleteScope;
597 }
598 }
599 let path = slashed(&module.path);
600 let external = self.workspace.iter().any(|(id, _)| {
601 id != module.owner
602 && self
603 .setup
604 .package(id)
605 .is_some_and(|other| other.external_entry.is_match(&path))
606 });
607 if external {
608 EntryReason::ExternalEntryGlob
609 } else {
610 EntryReason::Unknown
611 }
612 }
613
614 fn is_script_file(&self, abs: &Path) -> bool {
617 self.workspace.iter().any(|(id, _)| {
618 self.setup.package(id).is_some_and(|package| {
619 package
620 .scripts
621 .files
622 .iter()
623 .any(|file| join_normalized(&package.dir, file) == abs)
624 })
625 })
626 }
627
628 fn absence_of(&self, file: &Path) -> (Absence, Vec<IncompleteEntries>) {
632 let root = &self.workspace.root;
633 let abs = if file.is_absolute() {
634 file.to_path_buf()
635 } else {
636 join_normalized(root, &slashed(file))
637 };
638 let owner = self
639 .workspace
640 .iter()
641 .map(|(id, package)| (id, root.join(&package.dir)))
642 .filter(|(_, dir)| abs.starts_with(dir))
643 .max_by_key(|(_, dir)| dir.components().count())
644 .map(|(id, _)| id);
645 let Some(owner) = owner else {
646 return (Absence::NotDiscovered, Vec::new());
647 };
648 let Some(package) = self.setup.package(owner) else {
649 return (Absence::PackageExcluded, Vec::new());
650 };
651 let Some(rel) = relative(&abs, root) else {
652 return (Absence::NotDiscovered, Vec::new());
653 };
654 if !self.outside_graph.contains(&rel) {
655 return (Absence::NotDiscovered, Vec::new());
656 }
657 if rel.ends_with(".d.ts") {
658 return (Absence::DeclarationFile, Vec::new());
659 }
660 let Some(in_package) = relative(&abs, &package.dir) else {
661 return (Absence::OutsideProject, Vec::new());
662 };
663 if !package.project.is_match(&in_package) {
664 return (Absence::OutsideProject, Vec::new());
665 }
666 if package.ignore.matches_any(&in_package) || self.setup.global_ignore.matches_any(&rel) {
667 return (Absence::Ignored, Vec::new());
668 }
669 if package.unjudged_package_wide() {
670 return (Absence::EntriesIncomplete, self.incomplete_reasons(package));
671 }
672 (Absence::NotDiscovered, Vec::new())
673 }
674
675 fn widened_by(&self, file: &Path) -> Option<String> {
677 let root = &self.workspace.root;
678 let abs = if file.is_absolute() {
679 file.to_path_buf()
680 } else {
681 join_normalized(root, &slashed(file))
682 };
683 self.workspace
684 .iter()
685 .filter(|(_, package)| abs.starts_with(root.join(&package.dir)))
686 .max_by_key(|(_, package)| root.join(&package.dir).components().count())
687 .and_then(|(id, _)| self.setup.package(id))
688 .and_then(|package| package.widened_by.as_ref())
689 .map(|(path, line)| format!("{}:{line}", slashed(path)))
690 }
691
692 fn incomplete_reasons(&self, package: &PackageSetup) -> Vec<IncompleteEntries> {
694 let root = &self.workspace.root;
695 package
696 .incomplete_reasons
697 .iter()
698 .cloned()
699 .map(|mut reason| {
700 reason.file = reason
701 .file
702 .map(|path| relative(&path, root).map_or(path, std::path::PathBuf::from));
703 reason
704 })
705 .collect()
706 }
707
708 fn narrowed_scope(&self, owner: PackageId) -> (Vec<IncompleteEntries>, Vec<String>) {
711 match self.setup.package(owner) {
712 Some(package) if package.entries_incomplete => package.incomplete_scope.as_ref().map_or_else(
713 || (Vec::new(), Vec::new()),
714 |scope| (self.incomplete_reasons(package), scope.patterns.clone()),
715 ),
716 _ => (Vec::new(), Vec::new()),
717 }
718 }
719
720 fn is_published(&self, module: &Module, package_dir: &Path, rel: &str) -> bool {
722 let Some(package) = self.workspace.package(module.owner) else {
723 return false;
724 };
725 manifest_targets(package.manifest.value()).into_iter().any(|target| {
726 if target.path.contains('*') {
727 let globs = Globs::new(
728 source_patterns(&target.path)
729 .into_iter()
730 .chain(std::iter::once(target.path.trim_start_matches("./").to_owned()))
731 .map(|pattern| {
732 if target.from_exports {
733 crossing_stars(&pattern)
734 } else {
735 pattern
736 }
737 }),
738 );
739 globs.is_match(rel)
740 } else {
741 source_candidates(&target.path)
742 .into_iter()
743 .any(|candidate| package_dir.join(candidate) == module.abs)
744 }
745 })
746 }
747
748 fn importers(&self, module: &Module, walk: &Walk) -> Vec<Importer> {
749 let mut found = Vec::new();
750 for importer_id in self.graph.imported_by(module.id) {
751 let importer = self.graph.module(*importer_id);
752 for resolved in &importer.imports {
753 if !reached(&resolved.target).contains(&module.id) {
754 continue;
755 }
756 if let Some(import) = importer.info.imports.get(resolved.import) {
757 found.push(Importer {
758 file: slashed(&importer.path),
759 line: import.span.start_line,
760 kind: import.kind,
761 names: import.names.iter().map(describe_name).collect(),
762 reachable: walk.reachable.contains(importer_id),
763 });
764 }
765 }
766 }
767 found
768 }
769
770 fn chain_to_entry(&self, id: ModuleId) -> Option<Vec<ModuleId>> {
772 let modules = self.graph.modules();
773 let mut previous: HashMap<ModuleId, ModuleId> = HashMap::new();
774 let mut queue: VecDeque<ModuleId> = modules
775 .iter()
776 .filter(|module| module.is_entry)
777 .map(|module| module.id)
778 .collect();
779 let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
780 while let Some(current) = queue.pop_front() {
781 if current == id {
782 let mut chain = vec![id];
783 let mut at = id;
784 while let Some(before) = previous.get(&at) {
785 chain.push(*before);
786 at = *before;
787 }
788 chain.reverse();
789 return Some(chain);
790 }
791 for resolved in &modules[current.index()].imports {
792 for next in reached(&resolved.target) {
793 if seen.insert(*next) {
794 previous.insert(*next, current);
795 queue.push_back(*next);
796 }
797 }
798 }
799 }
800 None
801 }
802
803 fn explain_export(&self, module: &Module, name: &str, depth: usize, walk: &mut Walk) -> ExportReport {
804 let mut report = ExportReport {
805 name: name.to_owned(),
806 file: slashed(&module.path),
807 verdict: Verdict::NotAnExport,
808 entry_public: false,
809 tagged_public: false,
810 users: Vec::new(),
811 re_exports: Vec::new(),
812 in_used_signature: false,
813 is_re_export: false,
814 registered: false,
815 entries_incomplete: false,
816 already_explained: false,
817 };
818 let Some(export) = module.info.exports.iter().find(|export| export.name == name) else {
819 return report;
820 };
821 if !walk.seen.insert((module.id, name.to_owned())) {
822 report.already_explained = true;
823 report.verdict = Verdict::Unused;
824 return report;
825 }
826 report.entry_public = module.is_entry && !self.setup.includes_entry_exports(module.owner);
827 report.tagged_public = self.is_tagged_public(export);
828 report.is_re_export = matches!(export.kind, ExportKind::ReExport | ExportKind::StarReExport);
829 report.entries_incomplete = self.entries_incomplete(module.owner);
830 for importer_id in self.graph.imported_by(module.id) {
831 let importer = self.graph.module(*importer_id);
832 for resolved in &importer.imports {
833 if !reached(&resolved.target).contains(&module.id) {
834 continue;
835 }
836 let Some(import) = importer.info.imports.get(resolved.import) else {
837 continue;
838 };
839 if let Some(how) = uses_export(import, name) {
840 report.users.push(ExportUser {
841 file: slashed(&importer.path),
842 line: import.span.start_line,
843 how,
844 reachable: walk.reachable.contains(importer_id),
845 });
846 }
847 }
848 if depth < MAX_DEPTH {
849 self.explain_reexports(importer, module, name, depth, walk, &mut report.re_exports);
850 }
851 }
852 report.registered = self.setup.is_registered(module.owner, export);
853 let used = report.registered || self.exports.is_used(module.id, name);
854 report.in_used_signature = !used && self.in_used_signature(module, export, &mut HashSet::new());
855 report.verdict = if report.entry_public || report.tagged_public {
856 Verdict::Public
857 } else if used || report.in_used_signature {
858 Verdict::Used
859 } else if report.entries_incomplete {
860 Verdict::NotJudged
861 } else {
862 Verdict::Unused
863 };
864 report
865 }
866
867 fn explain_reexports(
869 &self,
870 importer: &Module,
871 module: &Module,
872 name: &str,
873 depth: usize,
874 walk: &mut Walk,
875 out: &mut Vec<ReExport>,
876 ) {
877 for export in &importer.info.exports {
878 let Some(from) = export.from.as_deref() else {
879 continue;
880 };
881 if importer.reexport_source(from) != Some(module.id) {
882 continue;
883 }
884 let forwarded = match export.kind {
885 ExportKind::ReExport => export.imported_name.as_deref().unwrap_or(&export.name) == name,
886 ExportKind::StarReExport if export.name == "*" => {
887 name != "default"
888 && !importer
889 .info
890 .exports
891 .iter()
892 .any(|own| own.name == name && own.from.is_none())
893 }
894 ExportKind::StarReExport => true,
895 _ => false,
896 };
897 if !forwarded {
898 continue;
899 }
900 let exposed = if export.kind == ExportKind::StarReExport && export.name == "*" {
901 name
902 } else {
903 export.name.as_str()
904 };
905 out.push(ReExport {
906 file: slashed(&importer.path),
907 name: exposed.to_owned(),
908 export: self.explain_export(importer, exposed, depth + 1, walk),
909 });
910 }
911 }
912}