1use std::collections::VecDeque;
7
8use skif_core::{HashMap, HashSet};
9use std::fmt::Write as _;
10use std::path::Path;
11
12use serde::Serialize;
13use skif_js_syntax::{ExportKind, Import, ImportKind, ImportedKind, ImportedName, ModuleInfo, is_declaration_file};
14use skif_workspace::PackageId;
15
16use crate::analysis::JsAnalysis;
17use crate::discover::relative;
18use crate::globs::Globs;
19use crate::graph::{Module, ModuleId, ResolvedImport, Target};
20use crate::input::IncompleteEntries;
21use crate::manifest_paths::{crossing_stars, manifest_targets, source_candidates, source_patterns};
22use crate::resolve::join_normalized;
23use crate::setup::PackageSetup;
24use crate::trace::dir_of;
25use crate::usage;
26
27const MAX_DEPTH: usize = 8;
29const MAX_LISTED: usize = 12;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "kebab-case")]
35#[non_exhaustive]
36pub enum EntryReason {
37 EntryGlob,
39 ConfigFile,
41 Script,
43 Published,
45 BinStub,
47 ExternalEntryGlob,
49 EnclosingEntryGlob,
52 IncompleteScope,
55 EnclosingIncompleteScope,
58 Unknown,
60}
61
62impl EntryReason {
63 const fn describe(self) -> &'static str {
64 match self {
65 Self::EntryGlob => {
66 "matches an entry glob of its package (defaults, configuration, test conventions or a plugin)"
67 }
68 Self::ConfigFile => "a configuration file of a tool",
69 Self::Script => "run by a script of package.json",
70 Self::Published => {
71 "published by package.json (main, module, types, bin, exports, n8n nodes and credentials)"
72 }
73 Self::BinStub => "loaded by a bin file of package.json that is no module (an extensionless node stub)",
74 Self::ExternalEntryGlob => "matches an entry glob of another package that points outside it",
75 Self::EnclosingEntryGlob => "matches an entry glob a plugin gives a package that contains its package",
76 Self::IncompleteScope => {
77 "may be an entry of a configuration that is not fully known, so it is treated as one (narrowed scope)"
78 }
79 Self::EnclosingIncompleteScope => {
80 "may be an entry of a configuration of a package that contains its package and is not fully known, so \
81 it is treated as one (narrowed scope)"
82 }
83 Self::Unknown => "unknown: no entry rule of the analysis accounts for it",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
90#[serde(rename_all = "kebab-case")]
91#[non_exhaustive]
92pub enum Absence {
93 UnusedFile,
95 PackageExcluded,
97 DeclarationFile,
99 EntriesIncomplete,
102 OutsideProject,
104 Ignored,
106 NotDiscovered,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113#[serde(rename_all = "kebab-case")]
114#[non_exhaustive]
115pub struct Importer {
116 pub file: String,
118 pub line: u32,
121 pub kind: ImportKind,
123 pub names: Vec<String>,
125 pub reachable: bool,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131#[serde(rename_all = "kebab-case")]
132#[non_exhaustive]
133pub struct ExportUser {
134 pub file: String,
136 pub line: u32,
139 pub how: String,
141 pub reachable: bool,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[serde(rename_all = "kebab-case")]
148#[non_exhaustive]
149pub enum Verdict {
150 NotAnExport,
152 Public,
154 Used,
156 Unused,
158 NotJudged,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "kebab-case")]
166#[non_exhaustive]
167pub struct ReExport {
168 pub file: String,
170 pub name: String,
172 pub export: ExportReport,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178#[allow(
179 clippy::struct_excessive_bools,
180 reason = "independent facts of one serialized report"
181)]
182#[serde(rename_all = "kebab-case")]
183#[non_exhaustive]
184pub struct ExportReport {
185 pub name: String,
187 pub file: String,
189 pub verdict: Verdict,
191 pub entry_public: bool,
193 pub tagged_public: bool,
195 pub users: Vec<ExportUser>,
197 pub re_exports: Vec<ReExport>,
199 pub in_used_signature: bool,
201 pub is_re_export: bool,
203 #[serde(skip_serializing_if = "is_false")]
205 pub registered: bool,
206 #[serde(skip_serializing_if = "is_false")]
209 pub entries_incomplete: bool,
210 #[serde(skip_serializing_if = "is_false")]
212 pub already_explained: bool,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217#[serde(rename_all = "kebab-case")]
218#[non_exhaustive]
219pub struct UsageReport {
220 pub file: String,
222 pub in_graph: bool,
224 pub absence: Option<Absence>,
226 #[serde(skip_serializing_if = "Vec::is_empty")]
230 pub incomplete: Vec<IncompleteEntries>,
231 #[serde(skip_serializing_if = "Vec::is_empty")]
236 pub incomplete_scope: Vec<String>,
237 #[serde(skip_serializing_if = "Option::is_none")]
241 pub scope_package: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
246 pub widened_by: Option<String>,
247 pub entry: Option<EntryReason>,
249 #[serde(skip_serializing_if = "is_false")]
252 pub build_source: bool,
253 pub imported_by: Vec<Importer>,
255 pub chain_from_entry: Option<Vec<String>>,
257 #[serde(skip_serializing_if = "is_false")]
259 pub chain_from_build_source: bool,
260 pub export: Option<ExportReport>,
262}
263
264impl UsageReport {
265 fn render_absence(&self, absence: Absence, out: &mut String) {
267 match absence {
268 Absence::UnusedFile => {
269 let _ = writeln!(out, " no entry file reaches it: reported by `unused-files`");
270 if !self.incomplete_scope.is_empty() {
271 let _ = writeln!(
272 out,
273 " the entry files of its package are not fully known, but the unknown ones can only match {} \
274 (narrowed scope); this file does not, so it is judged",
275 self.incomplete_scope.join(", ")
276 );
277 self.render_incomplete(out);
278 }
279 }
280 Absence::PackageExcluded => {
281 let _ = writeln!(out, " not analysed: its package is excluded from the run");
282 }
283 Absence::DeclarationFile => {
284 let _ = writeln!(
285 out,
286 " not in the module graph: a declaration file is never reported as unused"
287 );
288 }
289 Absence::EntriesIncomplete => {
290 let _ = writeln!(
291 out,
292 " not in the module graph, and not reported as unused: the entry files of its package are \
293 not fully known and may be anywhere in it (package-wide)"
294 );
295 if let Some(at) = &self.widened_by {
296 let _ = writeln!(
297 out,
298 " {at} imports a computed path, which may load any file of the package, so the narrowed \
299 scope of its configs is not used"
300 );
301 }
302 self.render_incomplete(out);
303 }
304 Absence::OutsideProject => {
305 let _ = writeln!(out, " not analysed: outside the project globs of its package");
306 }
307 Absence::Ignored => {
308 let _ = writeln!(out, " not analysed: matched by an `ignore` glob");
309 }
310 Absence::NotDiscovered => {
311 let _ = writeln!(
312 out,
313 " not analysed: missing, ignored by Git, or not a source file the analysis looks at"
314 );
315 }
316 }
317 }
318
319 fn render_incomplete(&self, out: &mut String) {
321 for incomplete in &self.incomplete {
322 let source = match (&incomplete.plugin, &incomplete.file) {
323 (Some(plugin), Some(file)) => format!("{plugin} reads {}: ", slashed(file)),
324 (None, Some(file)) => format!("{}: ", slashed(file)),
325 (Some(plugin), None) => format!("{plugin}: "),
326 (None, None) => String::new(),
327 };
328 let _ = writeln!(out, " {source}{}", incomplete.cause.describe());
329 }
330 }
331
332 #[must_use]
334 pub fn render(&self) -> String {
335 let mut out = String::new();
336 let _ = writeln!(out, "{}", self.file);
337 if let Some(absence) = self.absence {
338 self.render_absence(absence, &mut out);
339 return out;
340 }
341 if let Some(entry) = self.entry {
342 let _ = writeln!(out, " entry file: {}", entry.describe());
343 if entry == EntryReason::IncompleteScope {
344 let _ = writeln!(
345 out,
346 " the narrowed scope of its package: {}",
347 self.incomplete_scope.join(", ")
348 );
349 self.render_incomplete(&mut out);
350 }
351 if entry == EntryReason::EnclosingIncompleteScope {
352 let _ = writeln!(
353 out,
354 " the narrowed scope of the enclosing package `{}`: {}",
355 self.scope_package.as_deref().unwrap_or("."),
356 self.incomplete_scope.join(", ")
357 );
358 self.render_incomplete(&mut out);
359 }
360 }
361 if self.build_source {
362 let _ = writeln!(
363 out,
364 " build source: a plugin names it as a file a tool builds from (such as a Bazel rule's srcs or data), \
365 so it is used, but it is no entry and its exports are judged"
366 );
367 }
368 if self.imported_by.is_empty() {
369 let _ = writeln!(out, " imported by no module");
370 } else {
371 let _ = writeln!(out, " imported by {} place(s):", self.imported_by.len());
372 for importer in self.imported_by.iter().take(MAX_LISTED) {
373 if importer.line == 0 {
374 let _ = writeln!(out, " {} ({COMPONENT_USE})", importer.file);
375 continue;
376 }
377 let _ = writeln!(
378 out,
379 " {}:{} ({}: {})",
380 importer.file,
381 importer.line,
382 kind_label(importer.kind),
383 names_label(&importer.names)
384 );
385 }
386 if self.imported_by.len() > MAX_LISTED {
387 let _ = writeln!(out, " ... and {} more", self.imported_by.len() - MAX_LISTED);
388 }
389 }
390 match &self.chain_from_entry {
391 Some(chain) if self.chain_from_build_source => {
392 let _ = writeln!(out, " reached from a build source through: {}", chain.join(" -> "));
393 }
394 Some(chain) => {
395 let _ = writeln!(out, " reached from an entry through: {}", chain.join(" -> "));
396 }
397 None => {
398 let _ = writeln!(out, " no chain of imports from an entry file reaches it");
399 }
400 }
401 if let Some(export) = &self.export {
402 render_export(export, 0, &mut out);
403 }
404 out
405 }
406}
407
408const COMPONENT_USE: &str = "a template uses it as a registered component";
410
411fn import_line(resolved: &ResolvedImport, import: &Import) -> u32 {
414 if resolved.component { 0 } else { import.span.start_line }
415}
416
417const fn kind_label(kind: ImportKind) -> &'static str {
419 match kind {
420 ImportKind::Static => "static",
421 ImportKind::TypeOnly => "type-only",
422 ImportKind::SideEffect => "side-effect",
423 ImportKind::Dynamic => "dynamic",
424 ImportKind::Require => "require",
425 ImportKind::ImportMetaGlob => "import-meta-glob",
426 ImportKind::SourceGlob => "source-glob",
427 ImportKind::ExportFrom => "export-from",
428 ImportKind::Mock => "mock",
429 ImportKind::Url => "url",
430 }
431}
432
433fn names_label(names: &[String]) -> String {
434 if names.is_empty() {
435 "the whole module".to_owned()
436 } else {
437 names.join(", ")
438 }
439}
440
441fn render_export(export: &ExportReport, depth: usize, out: &mut String) {
442 let pad = " ".repeat(depth + 1);
443 if export.verdict == Verdict::NotAnExport {
444 let _ = writeln!(out, "{pad}`{}` is not an export of {}", export.name, export.file);
445 return;
446 }
447 if export.already_explained {
448 let _ = writeln!(
449 out,
450 "{pad}`{}` of {} is already explained above",
451 export.name, export.file
452 );
453 return;
454 }
455 let _ = writeln!(out, "{pad}export `{}` of {}:", export.name, export.file);
456 if export.entry_public {
457 let _ = writeln!(out, "{pad} the file is an entry, so its exports are public");
458 }
459 if export.tagged_public {
460 let _ = writeln!(out, "{pad} tagged `@public` (or another ignored tag)");
461 }
462 for user in &export.users {
463 let unreached = if user.reachable {
464 ""
465 } else {
466 "; no entry file reaches the importer"
467 };
468 if user.line == 0 {
469 let _ = writeln!(out, "{pad} imported by {} ({COMPONENT_USE}{unreached})", user.file);
470 continue;
471 }
472 let _ = writeln!(
473 out,
474 "{pad} imported by {}:{} ({}{unreached})",
475 user.file, user.line, user.how
476 );
477 }
478 for re_export in &export.re_exports {
479 let _ = writeln!(out, "{pad} re-exported by {} as `{}`:", re_export.file, re_export.name);
480 render_export(&re_export.export, depth + 1, out);
481 }
482 if export.users.is_empty() && export.re_exports.is_empty() {
483 let _ = writeln!(out, "{pad} no module imports it by name or as a whole");
484 }
485 if export.in_used_signature {
486 let _ = writeln!(
487 out,
488 "{pad} mentioned in the signature of a used export of the same file"
489 );
490 }
491 if export.is_re_export {
492 let _ = writeln!(out, "{pad} itself a re-export of another module");
493 }
494 if export.registered {
495 let _ = writeln!(out, "{pad} registered as a custom element");
496 }
497 if export.entries_incomplete {
498 let _ = writeln!(
499 out,
500 "{pad} the entry files of its package are not fully known and this file may be one, so its exports are not judged"
501 );
502 }
503 let verdict = match export.verdict {
504 Verdict::Public => "public",
505 Verdict::Used => "used by another module",
506 Verdict::NotJudged => "not judged: no known module uses it, but its package's entries are incomplete",
507 _ => "not used by another module",
508 };
509 let _ = writeln!(out, "{pad}=> {verdict}");
510}
511
512fn describe_name(name: &ImportedName) -> String {
513 match &name.kind {
514 ImportedKind::Default => "default".to_owned(),
515 ImportedKind::Named(export) => export.clone(),
516 ImportedKind::Namespace if name.escapes => "the namespace, used as a whole".to_owned(),
517 ImportedKind::Namespace if name.accessed_members.is_empty() => "the namespace, no member read".to_owned(),
518 ImportedKind::Namespace => format!("the namespace, members {}", name.accessed_members.join(", ")),
519 }
520}
521
522fn describe_imported(kind: ImportKind, name: &ImportedName) -> String {
524 let star = kind == ImportKind::ExportFrom
525 && name.kind == ImportedKind::Namespace
526 && !name.escapes
527 && name.accessed_members.is_empty();
528 if star {
529 "every export, re-exported".to_owned()
530 } else {
531 describe_name(name)
532 }
533}
534
535fn reached(target: &Target) -> &[ModuleId] {
537 match target {
538 Target::Module(id) => std::slice::from_ref(id),
539 Target::Modules(ids) => ids.as_slice(),
540 _ => &[],
541 }
542}
543
544fn uses_export(importer: &ModuleInfo, import: &Import, export: &str, is_type: bool) -> Option<String> {
547 if matches!(
548 import.kind,
549 ImportKind::ExportFrom | ImportKind::SideEffect | ImportKind::Mock
550 ) {
551 return None;
552 }
553 if import.names.is_empty() {
554 let reads = !is_type || !usage::loads_module_value(import.kind);
555 return reads.then(|| "the whole module".to_owned());
556 }
557 import.names.iter().find_map(|name| {
558 let hit = match &name.kind {
559 ImportedKind::Default => export == "default",
560 ImportedKind::Named(named) => named == export,
561 ImportedKind::Namespace => {
562 let whole = name.escapes && (!is_type || usage::exports_binding(importer, name));
563 whole || name.accessed_members.iter().any(|member| member == export)
564 }
565 };
566 hit.then(|| describe_name(name))
567 })
568}
569
570struct Walk {
572 reachable: HashSet<ModuleId>,
573 seen: HashSet<(ModuleId, String)>,
574}
575
576#[allow(clippy::trivially_copy_pass_by_ref)]
577fn is_false(value: &bool) -> bool {
578 !*value
579}
580
581fn slashed(path: &Path) -> String {
582 path.to_string_lossy().replace('\\', "/")
583}
584
585impl JsAnalysis<'_> {
586 #[must_use]
589 pub fn explain_usage(&self, file: &Path, export: Option<&str>) -> UsageReport {
590 let Some(module) = self
591 .graph
592 .modules()
593 .iter()
594 .find(|module| module.path == file || module.abs == file)
595 else {
596 let unused = self.unused_files.iter().find(|unused| unused.path == file);
597 let (absence, incomplete, incomplete_scope) = if let Some(unused) = unused {
598 let (incomplete, scope) = self.narrowed_scope(unused.owner);
599 (Absence::UnusedFile, incomplete, scope)
600 } else {
601 let (absence, incomplete) = self.absence_of(file);
602 (absence, incomplete, Vec::new())
603 };
604 let widened_by = (absence == Absence::EntriesIncomplete)
605 .then(|| self.widened_by(file))
606 .flatten();
607 return UsageReport {
608 file: slashed(file),
609 in_graph: false,
610 absence: Some(absence),
611 incomplete,
612 incomplete_scope,
613 scope_package: None,
614 widened_by,
615 entry: None,
616 build_source: false,
617 imported_by: Vec::new(),
618 chain_from_entry: None,
619 chain_from_build_source: false,
620 export: None,
621 };
622 };
623 let mut walk = Walk {
624 reachable: self.reachable_from_entries(),
625 seen: HashSet::default(),
626 };
627 let chain = self.chain_to_entry(module.id);
628 let chain_from_build_source = chain
629 .as_ref()
630 .and_then(|chain| chain.first())
631 .is_some_and(|first| self.graph.module(*first).is_build_source);
632 let chain = chain.map(|chain| chain.iter().map(|id| slashed(&self.graph.module(*id).path)).collect());
633 let entry = module.is_entry.then(|| self.entry_reason(module));
634 let scope_owner = match entry {
635 Some(EntryReason::IncompleteScope) => Some(module.owner),
636 Some(EntryReason::EnclosingIncompleteScope) => self.enclosing_incomplete_scope(module),
637 _ => None,
638 };
639 let (incomplete, incomplete_scope) =
640 scope_owner.map_or_else(|| (Vec::new(), Vec::new()), |owner| self.narrowed_scope(owner));
641 let scope_package = match entry {
642 Some(EntryReason::EnclosingIncompleteScope) => {
643 scope_owner.and_then(|owner| self.workspace.package(owner)).map(dir_of)
644 }
645 _ => None,
646 };
647 UsageReport {
648 file: slashed(&module.path),
649 in_graph: true,
650 absence: None,
651 incomplete,
652 incomplete_scope,
653 scope_package,
654 widened_by: None,
655 entry,
656 build_source: module.is_build_source,
657 imported_by: self.importers(module, &walk),
658 chain_from_entry: chain,
659 chain_from_build_source,
660 export: export.map(|name| self.explain_export(module, name, 0, &mut walk, false)),
661 }
662 }
663
664 pub(crate) fn reachable_from_entries(&self) -> HashSet<ModuleId> {
665 let modules = self.graph.modules();
666 let mut queue: VecDeque<ModuleId> = modules
667 .iter()
668 .filter(|module| module.is_entry || module.is_build_source)
669 .map(|module| module.id)
670 .collect();
671 let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
672 while let Some(current) = queue.pop_front() {
673 for resolved in &modules[current.index()].imports {
674 for next in reached(&resolved.target) {
675 if seen.insert(*next) {
676 queue.push_back(*next);
677 }
678 }
679 }
680 }
681 seen
682 }
683
684 fn entry_reason(&self, module: &Module) -> EntryReason {
686 if let Some(package) = self.setup.package(module.owner)
687 && let Some(rel) = relative(&module.abs, &package.dir)
688 {
689 if package.entry.is_match(&rel) {
690 return EntryReason::EntryGlob;
691 }
692 if package.config_files.contains(&module.abs) {
693 return EntryReason::ConfigFile;
694 }
695 if self.is_script_file(&module.abs) {
696 return EntryReason::Script;
697 }
698 if self.is_published(module, &package.dir, &rel) {
699 return EntryReason::Published;
700 }
701 if let Some(&reason) = self.entry_origins.get(&module.abs) {
702 return reason;
703 }
704 if package.in_incomplete_scope(&rel) {
705 return EntryReason::IncompleteScope;
706 }
707 }
708 let enclosing = self.workspace.iter().any(|(id, _)| {
709 id != module.owner
710 && self.setup.package(id).is_some_and(|other| {
711 relative(&module.abs, &other.dir).is_some_and(|rel| other.shared_entry.is_match(&rel))
712 })
713 });
714 if enclosing {
715 return EntryReason::EnclosingEntryGlob;
716 }
717 let path = slashed(&module.path);
718 let external = self.workspace.iter().any(|(id, _)| {
719 id != module.owner
720 && self
721 .setup
722 .package(id)
723 .is_some_and(|other| other.external_entry.is_match(&path))
724 });
725 if external {
726 return EntryReason::ExternalEntryGlob;
727 }
728 if self.enclosing_incomplete_scope(module).is_some() {
729 return EntryReason::EnclosingIncompleteScope;
730 }
731 EntryReason::Unknown
732 }
733
734 fn enclosing_incomplete_scope(&self, module: &Module) -> Option<PackageId> {
737 let root = &self.workspace.root;
738 self.workspace
739 .iter()
740 .filter(|(id, _)| *id != module.owner)
741 .filter(|(id, _)| {
742 self.setup.package(*id).is_some_and(|other| {
743 relative(&module.abs, &other.dir).is_some_and(|rel| other.in_incomplete_scope(&rel))
744 })
745 })
746 .max_by_key(|(_, package)| root.join(&package.dir).components().count())
747 .map(|(id, _)| id)
748 }
749
750 fn is_script_file(&self, abs: &Path) -> bool {
753 self.workspace.iter().any(|(id, _)| {
754 self.setup.package(id).is_some_and(|package| {
755 package
756 .scripts
757 .files
758 .iter()
759 .any(|file| join_normalized(&package.dir, file) == abs)
760 })
761 })
762 }
763
764 fn absence_of(&self, file: &Path) -> (Absence, Vec<IncompleteEntries>) {
768 let root = &self.workspace.root;
769 let abs = if file.is_absolute() {
770 file.to_path_buf()
771 } else {
772 join_normalized(root, &slashed(file))
773 };
774 let owner = self
775 .workspace
776 .iter()
777 .map(|(id, package)| (id, root.join(&package.dir)))
778 .filter(|(_, dir)| abs.starts_with(dir))
779 .max_by_key(|(_, dir)| dir.components().count())
780 .map(|(id, _)| id);
781 let Some(owner) = owner else {
782 return (Absence::NotDiscovered, Vec::new());
783 };
784 let Some(package) = self.setup.package(owner) else {
785 return (Absence::PackageExcluded, Vec::new());
786 };
787 let Some(rel) = relative(&abs, root) else {
788 return (Absence::NotDiscovered, Vec::new());
789 };
790 if !self.outside_graph.contains(&rel) {
791 return (Absence::NotDiscovered, Vec::new());
792 }
793 if is_declaration_file(&rel) {
794 return (Absence::DeclarationFile, Vec::new());
795 }
796 let Some(in_package) = relative(&abs, &package.dir) else {
797 return (Absence::OutsideProject, Vec::new());
798 };
799 if !package.project.is_match(&in_package) {
800 return (Absence::OutsideProject, Vec::new());
801 }
802 if package.ignore.matches_any(&in_package) || self.setup.global_ignore.matches_any(&rel) {
803 return (Absence::Ignored, Vec::new());
804 }
805 if package.unjudged_package_wide() {
806 return (Absence::EntriesIncomplete, self.incomplete_reasons(package));
807 }
808 (Absence::NotDiscovered, Vec::new())
809 }
810
811 fn widened_by(&self, file: &Path) -> Option<String> {
813 let root = &self.workspace.root;
814 let abs = if file.is_absolute() {
815 file.to_path_buf()
816 } else {
817 join_normalized(root, &slashed(file))
818 };
819 self.workspace
820 .iter()
821 .filter(|(_, package)| abs.starts_with(root.join(&package.dir)))
822 .max_by_key(|(_, package)| root.join(&package.dir).components().count())
823 .and_then(|(id, _)| self.setup.package(id))
824 .and_then(|package| package.widened_by.as_ref())
825 .map(|(path, line)| format!("{}:{line}", slashed(path)))
826 }
827
828 fn incomplete_reasons(&self, package: &PackageSetup) -> Vec<IncompleteEntries> {
830 let root = &self.workspace.root;
831 package
832 .incomplete_reasons
833 .iter()
834 .cloned()
835 .map(|mut reason| {
836 reason.file = reason
837 .file
838 .map(|path| relative(&path, root).map_or(path, std::path::PathBuf::from));
839 reason
840 })
841 .collect()
842 }
843
844 fn narrowed_scope(&self, owner: PackageId) -> (Vec<IncompleteEntries>, Vec<String>) {
847 match self.setup.package(owner) {
848 Some(package) if package.entries_incomplete => package.incomplete_scope.as_ref().map_or_else(
849 || (Vec::new(), Vec::new()),
850 |scope| (self.incomplete_reasons(package), scope.patterns.clone()),
851 ),
852 _ => (Vec::new(), Vec::new()),
853 }
854 }
855
856 fn is_published(&self, module: &Module, package_dir: &Path, rel: &str) -> bool {
858 let Some(package) = self.workspace.package(module.owner) else {
859 return false;
860 };
861 manifest_targets(package.manifest.value()).into_iter().any(|target| {
862 if target.path.contains('*') {
863 let globs = Globs::new(
864 source_patterns(&target.path)
865 .into_iter()
866 .chain(std::iter::once(target.path.trim_start_matches("./").to_owned()))
867 .map(|pattern| {
868 if target.from_exports {
869 crossing_stars(&pattern)
870 } else {
871 pattern
872 }
873 }),
874 );
875 globs.is_match(rel)
876 } else {
877 source_candidates(&target.path)
878 .into_iter()
879 .any(|candidate| package_dir.join(candidate) == module.abs)
880 }
881 })
882 }
883
884 fn importers(&self, module: &Module, walk: &Walk) -> Vec<Importer> {
885 let mut found = Vec::new();
886 for importer_id in self.graph.imported_by(module.id) {
887 let importer = self.graph.module(*importer_id);
888 for resolved in &importer.imports {
889 if !reached(&resolved.target).contains(&module.id) {
890 continue;
891 }
892 if let Some(import) = importer.info.imports.get(resolved.import) {
893 found.push(Importer {
894 file: slashed(&importer.path),
895 line: import_line(resolved, import),
896 kind: import.kind,
897 names: import
898 .names
899 .iter()
900 .map(|name| describe_imported(import.kind, name))
901 .collect(),
902 reachable: walk.reachable.contains(importer_id),
903 });
904 }
905 }
906 }
907 found
908 }
909
910 fn chain_to_entry(&self, id: ModuleId) -> Option<Vec<ModuleId>> {
912 let modules = self.graph.modules();
913 let mut previous: HashMap<ModuleId, ModuleId> = HashMap::default();
914 let mut queue: VecDeque<ModuleId> = modules
915 .iter()
916 .filter(|module| module.is_entry || module.is_build_source)
917 .map(|module| module.id)
918 .collect();
919 let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
920 while let Some(current) = queue.pop_front() {
921 if current == id {
922 let mut chain = vec![id];
923 let mut at = id;
924 while let Some(before) = previous.get(&at) {
925 chain.push(*before);
926 at = *before;
927 }
928 chain.reverse();
929 return Some(chain);
930 }
931 for resolved in &modules[current.index()].imports {
932 for next in reached(&resolved.target) {
933 if seen.insert(*next) {
934 previous.insert(*next, current);
935 queue.push_back(*next);
936 }
937 }
938 }
939 }
940 None
941 }
942
943 fn explain_export(
947 &self,
948 module: &Module,
949 name: &str,
950 depth: usize,
951 walk: &mut Walk,
952 through_star: bool,
953 ) -> ExportReport {
954 let mut report = ExportReport {
955 name: name.to_owned(),
956 file: slashed(&module.path),
957 verdict: Verdict::NotAnExport,
958 entry_public: false,
959 tagged_public: false,
960 users: Vec::new(),
961 re_exports: Vec::new(),
962 in_used_signature: false,
963 is_re_export: false,
964 registered: false,
965 entries_incomplete: false,
966 already_explained: false,
967 };
968 let export = module.info.exports.iter().find(|export| export.name == name);
969 if export.is_none() && !through_star {
970 return report;
971 }
972 if !walk.seen.insert((module.id, name.to_owned())) {
973 report.already_explained = true;
974 report.verdict = Verdict::Unused;
975 return report;
976 }
977 report.entry_public = module.is_entry && !self.setup.includes_entry_exports(module.owner);
978 report.tagged_public = export.is_some_and(|export| self.is_tagged_public(export));
979 report.is_re_export =
980 export.is_none_or(|export| matches!(export.kind, ExportKind::ReExport | ExportKind::StarReExport));
981 report.entries_incomplete = !self.judges_exports_of(module);
982 let is_type = export.is_some_and(|export| export.is_type);
983 for importer_id in self.graph.imported_by(module.id) {
984 let importer = self.graph.module(*importer_id);
985 for resolved in &importer.imports {
986 if !reached(&resolved.target).contains(&module.id) {
987 continue;
988 }
989 let Some(import) = importer.info.imports.get(resolved.import) else {
990 continue;
991 };
992 if let Some(how) = uses_export(&importer.info, import, name, is_type) {
993 report.users.push(ExportUser {
994 file: slashed(&importer.path),
995 line: import_line(resolved, import),
996 how,
997 reachable: walk.reachable.contains(importer_id),
998 });
999 }
1000 }
1001 if depth < MAX_DEPTH {
1002 self.explain_reexports(importer, module, name, depth, walk, &mut report.re_exports);
1003 }
1004 }
1005 report.registered = export.is_some_and(|export| self.setup.is_registered(module.owner, export));
1006 let forwarded_use = export.is_none()
1007 && (!report.users.is_empty()
1008 || report
1009 .re_exports
1010 .iter()
1011 .any(|re_export| matches!(re_export.export.verdict, Verdict::Used | Verdict::Public)));
1012 let used = report.registered || forwarded_use || self.exports.is_used(module.id, name);
1013 report.in_used_signature = !used && export.is_some_and(|export| self.in_used_signature(module, export));
1014 report.verdict = if report.entry_public || report.tagged_public {
1015 Verdict::Public
1016 } else if used || report.in_used_signature {
1017 Verdict::Used
1018 } else if report.entries_incomplete {
1019 Verdict::NotJudged
1020 } else {
1021 Verdict::Unused
1022 };
1023 report
1024 }
1025
1026 fn explain_reexports(
1028 &self,
1029 importer: &Module,
1030 module: &Module,
1031 name: &str,
1032 depth: usize,
1033 walk: &mut Walk,
1034 out: &mut Vec<ReExport>,
1035 ) {
1036 for export in &importer.info.exports {
1037 let Some(from) = export.from.as_deref() else {
1038 continue;
1039 };
1040 if importer.reexport_source(from) != Some(module.id) {
1041 continue;
1042 }
1043 let forwarded = match export.kind {
1044 ExportKind::ReExport => export.imported_name.as_deref().unwrap_or(&export.name) == name,
1045 ExportKind::StarReExport if export.name == "*" => {
1046 name != "default"
1047 && !importer
1048 .info
1049 .exports
1050 .iter()
1051 .any(|own| own.name == name && own.from.is_none())
1052 }
1053 ExportKind::StarReExport => true,
1054 _ => false,
1055 };
1056 if !forwarded {
1057 continue;
1058 }
1059 let through_star = export.kind == ExportKind::StarReExport && export.name == "*";
1060 let exposed = if through_star { name } else { export.name.as_str() };
1061 out.push(ReExport {
1062 file: slashed(&importer.path),
1063 name: exposed.to_owned(),
1064 export: self.explain_export(importer, exposed, depth + 1, walk, through_star),
1065 });
1066 }
1067 }
1068}