use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt::Write as _;
use std::path::Path;
use serde::Serialize;
use skif_js_syntax::{ExportKind, Import, ImportKind, ImportedKind, ImportedName};
use crate::analysis::JsAnalysis;
use crate::discover::relative;
use crate::globs::Globs;
use crate::graph::{Module, ModuleId, Target};
use crate::manifest_paths::{crossing_stars, manifest_targets, source_candidates, source_patterns};
const MAX_DEPTH: usize = 8;
const MAX_LISTED: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum EntryReason {
EntryGlob,
ConfigFile,
Script,
Published,
ExternalEntryGlob,
Unknown,
}
impl EntryReason {
const fn describe(self) -> &'static str {
match self {
Self::EntryGlob => {
"matches an entry glob of its package (defaults, configuration, test conventions or a plugin)"
}
Self::ConfigFile => "a configuration file of a tool",
Self::Script => "run by a script of package.json",
Self::Published => "published by package.json (main, module, types, bin, exports)",
Self::ExternalEntryGlob => "matches an entry glob of another package that points outside it",
Self::Unknown => "unknown: no entry rule of the analysis accounts for it",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Absence {
UnusedFile,
NotAnalysed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct Importer {
pub file: String,
pub line: u32,
pub kind: ImportKind,
pub names: Vec<String>,
pub reachable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct ExportUser {
pub file: String,
pub line: u32,
pub how: String,
pub reachable: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Verdict {
NotAnExport,
Public,
Used,
Unused,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct ReExport {
pub file: String,
pub name: String,
pub export: ExportReport,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[allow(
clippy::struct_excessive_bools,
reason = "independent facts of one serialized report"
)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct ExportReport {
pub name: String,
pub file: String,
pub verdict: Verdict,
pub entry_public: bool,
pub tagged_public: bool,
pub users: Vec<ExportUser>,
pub re_exports: Vec<ReExport>,
pub in_used_signature: bool,
pub is_re_export: bool,
#[serde(skip_serializing_if = "is_false")]
pub already_explained: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct UsageReport {
pub file: String,
pub in_graph: bool,
pub absence: Option<Absence>,
pub entry: Option<EntryReason>,
pub imported_by: Vec<Importer>,
pub chain_from_entry: Option<Vec<String>>,
pub export: Option<ExportReport>,
}
impl UsageReport {
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
let _ = writeln!(out, "{}", self.file);
if let Some(absence) = self.absence {
match absence {
Absence::UnusedFile => {
let _ = writeln!(out, " no entry file reaches it: reported by `unused-files`");
}
Absence::NotAnalysed => {
let _ = writeln!(
out,
" not in the module graph, and not reported as unused: it is ignored, outside the project \
globs, a declaration file, in a package excluded from the run, or in a package whose entry \
files are not fully known"
);
}
}
return out;
}
if let Some(entry) = self.entry {
let _ = writeln!(out, " entry file: {}", entry.describe());
}
if self.imported_by.is_empty() {
let _ = writeln!(out, " imported by no module");
} else {
let _ = writeln!(out, " imported by {} place(s):", self.imported_by.len());
for importer in self.imported_by.iter().take(MAX_LISTED) {
let _ = writeln!(
out,
" {}:{} ({}: {})",
importer.file,
importer.line,
kind_label(importer.kind),
names_label(&importer.names)
);
}
if self.imported_by.len() > MAX_LISTED {
let _ = writeln!(out, " ... and {} more", self.imported_by.len() - MAX_LISTED);
}
}
match &self.chain_from_entry {
Some(chain) => {
let _ = writeln!(out, " reached from an entry through: {}", chain.join(" -> "));
}
None => {
let _ = writeln!(out, " no chain of imports from an entry file reaches it");
}
}
if let Some(export) = &self.export {
render_export(export, 0, &mut out);
}
out
}
}
const fn kind_label(kind: ImportKind) -> &'static str {
match kind {
ImportKind::Static => "static",
ImportKind::TypeOnly => "type-only",
ImportKind::SideEffect => "side-effect",
ImportKind::Dynamic => "dynamic",
ImportKind::Require => "require",
ImportKind::ImportMetaGlob => "import-meta-glob",
ImportKind::ExportFrom => "export-from",
}
}
fn names_label(names: &[String]) -> String {
if names.is_empty() {
"the whole module".to_owned()
} else {
names.join(", ")
}
}
fn render_export(export: &ExportReport, depth: usize, out: &mut String) {
let pad = " ".repeat(depth + 1);
if export.verdict == Verdict::NotAnExport {
let _ = writeln!(out, "{pad}`{}` is not an export of {}", export.name, export.file);
return;
}
if export.already_explained {
let _ = writeln!(
out,
"{pad}`{}` of {} is already explained above",
export.name, export.file
);
return;
}
let _ = writeln!(out, "{pad}export `{}` of {}:", export.name, export.file);
if export.entry_public {
let _ = writeln!(out, "{pad} the file is an entry, so its exports are public");
}
if export.tagged_public {
let _ = writeln!(out, "{pad} tagged `@public` (or another ignored tag)");
}
for user in &export.users {
let unreached = if user.reachable {
""
} else {
"; no entry file reaches the importer"
};
let _ = writeln!(
out,
"{pad} imported by {}:{} ({}{unreached})",
user.file, user.line, user.how
);
}
for re_export in &export.re_exports {
let _ = writeln!(out, "{pad} re-exported by {} as `{}`:", re_export.file, re_export.name);
render_export(&re_export.export, depth + 1, out);
}
if export.users.is_empty() && export.re_exports.is_empty() {
let _ = writeln!(out, "{pad} no module imports it by name or as a whole");
}
if export.in_used_signature {
let _ = writeln!(
out,
"{pad} mentioned in the signature of a used export of the same file"
);
}
if export.is_re_export {
let _ = writeln!(out, "{pad} itself a re-export of another module");
}
let verdict = match export.verdict {
Verdict::Public => "public",
Verdict::Used => "used by another module",
_ => "not used by another module",
};
let _ = writeln!(out, "{pad}=> {verdict}");
}
fn describe_name(name: &ImportedName) -> String {
match &name.kind {
ImportedKind::Default => "default".to_owned(),
ImportedKind::Named(export) => export.clone(),
ImportedKind::Namespace if name.escapes => "the namespace, used as a whole".to_owned(),
ImportedKind::Namespace => format!("the namespace, members {}", name.accessed_members.join(", ")),
}
}
fn reached(target: &Target) -> &[ModuleId] {
match target {
Target::Module(id) => std::slice::from_ref(id),
Target::Modules(ids) => ids.as_slice(),
_ => &[],
}
}
fn uses_export(import: &Import, export: &str) -> Option<String> {
if matches!(import.kind, ImportKind::ExportFrom | ImportKind::SideEffect) {
return None;
}
if import.names.is_empty() {
return Some("the whole module".to_owned());
}
import.names.iter().find_map(|name| {
let hit = match &name.kind {
ImportedKind::Default => export == "default",
ImportedKind::Named(imported) => imported == export,
ImportedKind::Namespace => name.escapes || name.accessed_members.iter().any(|member| member == export),
};
hit.then(|| describe_name(name))
})
}
struct Walk {
reachable: HashSet<ModuleId>,
seen: HashSet<(ModuleId, String)>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(value: &bool) -> bool {
!*value
}
fn slashed(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
impl JsAnalysis<'_> {
#[must_use]
pub fn explain_usage(&self, file: &Path, export: Option<&str>) -> UsageReport {
let Some(module) = self
.graph
.modules()
.iter()
.find(|module| module.path == file || module.abs == file)
else {
let unused = self.unused_files.iter().any(|unused| unused.path == file);
return UsageReport {
file: slashed(file),
in_graph: false,
absence: Some(if unused {
Absence::UnusedFile
} else {
Absence::NotAnalysed
}),
entry: None,
imported_by: Vec::new(),
chain_from_entry: None,
export: None,
};
};
let mut walk = Walk {
reachable: self.reachable_from_entries(),
seen: HashSet::new(),
};
let chain = self
.chain_to_entry(module.id)
.map(|chain| chain.iter().map(|id| slashed(&self.graph.module(*id).path)).collect());
UsageReport {
file: slashed(&module.path),
in_graph: true,
absence: None,
entry: module.is_entry.then(|| self.entry_reason(module)),
imported_by: self.importers(module, &walk),
chain_from_entry: chain,
export: export.map(|name| self.explain_export(module, name, 0, &mut walk)),
}
}
fn reachable_from_entries(&self) -> HashSet<ModuleId> {
let modules = self.graph.modules();
let mut queue: VecDeque<ModuleId> = modules
.iter()
.filter(|module| module.is_entry)
.map(|module| module.id)
.collect();
let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
while let Some(current) = queue.pop_front() {
for resolved in &modules[current.index()].imports {
for next in reached(&resolved.target) {
if seen.insert(*next) {
queue.push_back(*next);
}
}
}
}
seen
}
fn entry_reason(&self, module: &Module) -> EntryReason {
if let Some(package) = self.setup.package(module.owner)
&& let Some(rel) = relative(&module.abs, &package.dir)
{
if package.entry.is_match(&rel) {
return EntryReason::EntryGlob;
}
if package.config_files.contains(&module.abs) {
return EntryReason::ConfigFile;
}
if package.scripts.files.contains(&rel) {
return EntryReason::Script;
}
if self.is_published(module, &package.dir, &rel) {
return EntryReason::Published;
}
}
let path = slashed(&module.path);
let external = self.workspace.iter().any(|(id, _)| {
id != module.owner
&& self
.setup
.package(id)
.is_some_and(|other| other.external_entry.is_match(&path))
});
if external {
EntryReason::ExternalEntryGlob
} else {
EntryReason::Unknown
}
}
fn is_published(&self, module: &Module, package_dir: &Path, rel: &str) -> bool {
let Some(package) = self.workspace.package(module.owner) else {
return false;
};
manifest_targets(package.manifest.value()).into_iter().any(|target| {
if target.path.contains('*') {
let globs = Globs::new(
source_patterns(&target.path)
.into_iter()
.chain(std::iter::once(target.path.trim_start_matches("./").to_owned()))
.map(|pattern| {
if target.from_exports {
crossing_stars(&pattern)
} else {
pattern
}
}),
);
globs.is_match(rel)
} else {
source_candidates(&target.path)
.into_iter()
.any(|candidate| package_dir.join(candidate) == module.abs)
}
})
}
fn importers(&self, module: &Module, walk: &Walk) -> Vec<Importer> {
let mut found = Vec::new();
for importer_id in self.graph.imported_by(module.id) {
let importer = self.graph.module(*importer_id);
for resolved in &importer.imports {
if !reached(&resolved.target).contains(&module.id) {
continue;
}
if let Some(import) = importer.info.imports.get(resolved.import) {
found.push(Importer {
file: slashed(&importer.path),
line: import.span.start_line,
kind: import.kind,
names: import.names.iter().map(describe_name).collect(),
reachable: walk.reachable.contains(importer_id),
});
}
}
}
found
}
fn chain_to_entry(&self, id: ModuleId) -> Option<Vec<ModuleId>> {
let modules = self.graph.modules();
let mut previous: HashMap<ModuleId, ModuleId> = HashMap::new();
let mut queue: VecDeque<ModuleId> = modules
.iter()
.filter(|module| module.is_entry)
.map(|module| module.id)
.collect();
let mut seen: HashSet<ModuleId> = queue.iter().copied().collect();
while let Some(current) = queue.pop_front() {
if current == id {
let mut chain = vec![id];
let mut at = id;
while let Some(before) = previous.get(&at) {
chain.push(*before);
at = *before;
}
chain.reverse();
return Some(chain);
}
for resolved in &modules[current.index()].imports {
for next in reached(&resolved.target) {
if seen.insert(*next) {
previous.insert(*next, current);
queue.push_back(*next);
}
}
}
}
None
}
fn explain_export(&self, module: &Module, name: &str, depth: usize, walk: &mut Walk) -> ExportReport {
let mut report = ExportReport {
name: name.to_owned(),
file: slashed(&module.path),
verdict: Verdict::NotAnExport,
entry_public: false,
tagged_public: false,
users: Vec::new(),
re_exports: Vec::new(),
in_used_signature: false,
is_re_export: false,
already_explained: false,
};
let Some(export) = module.info.exports.iter().find(|export| export.name == name) else {
return report;
};
if !walk.seen.insert((module.id, name.to_owned())) {
report.already_explained = true;
report.verdict = Verdict::Unused;
return report;
}
report.entry_public = module.is_entry && !self.setup.includes_entry_exports(module.owner);
report.tagged_public = self.is_tagged_public(export);
report.is_re_export = matches!(export.kind, ExportKind::ReExport | ExportKind::StarReExport);
for importer_id in self.graph.imported_by(module.id) {
let importer = self.graph.module(*importer_id);
for resolved in &importer.imports {
if !reached(&resolved.target).contains(&module.id) {
continue;
}
let Some(import) = importer.info.imports.get(resolved.import) else {
continue;
};
if let Some(how) = uses_export(import, name) {
report.users.push(ExportUser {
file: slashed(&importer.path),
line: import.span.start_line,
how,
reachable: walk.reachable.contains(importer_id),
});
}
}
if depth < MAX_DEPTH {
self.explain_reexports(importer, module, name, depth, walk, &mut report.re_exports);
}
}
let used = self.exports.is_used(module.id, name);
report.in_used_signature = !used && self.in_used_signature(module, export, &mut HashSet::new());
report.verdict = if report.entry_public || report.tagged_public {
Verdict::Public
} else if used || report.in_used_signature {
Verdict::Used
} else {
Verdict::Unused
};
report
}
fn explain_reexports(
&self,
importer: &Module,
module: &Module,
name: &str,
depth: usize,
walk: &mut Walk,
out: &mut Vec<ReExport>,
) {
for export in &importer.info.exports {
let Some(from) = export.from.as_deref() else {
continue;
};
if importer.reexport_source(from) != Some(module.id) {
continue;
}
let forwarded = match export.kind {
ExportKind::ReExport => export.imported_name.as_deref().unwrap_or(&export.name) == name,
ExportKind::StarReExport if export.name == "*" => {
name != "default"
&& !importer
.info
.exports
.iter()
.any(|own| own.name == name && own.from.is_none())
}
ExportKind::StarReExport => true,
_ => false,
};
if !forwarded {
continue;
}
let exposed = if export.kind == ExportKind::StarReExport && export.name == "*" {
name
} else {
export.name.as_str()
};
out.push(ReExport {
file: slashed(&importer.path),
name: exposed.to_owned(),
export: self.explain_export(importer, exposed, depth + 1, walk),
});
}
}
}