use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use skif_config::{Config, JsDiscovery};
use skif_core::{FileSystem, timing};
use skif_js_syntax::{Export, ExportKind, ParseOptions, parse_module};
use skif_workspace::{PackageId, Workspace, map_ordered};
use crate::deps::DependencyUsage;
use crate::discover::{Candidate, discover, relative, take_file_links};
use crate::entries::entry_files;
use crate::git_index::{IndexCache, Listing, indexed_fs};
use crate::graph::{self, Inputs, Module, ModuleGraph};
use crate::input::InputSource;
use crate::resolve::ModuleResolver;
use crate::setup::Setup;
use crate::specifier::package_name;
use crate::usage::ExportUsage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnusedFile {
pub path: PathBuf,
pub owner: PackageId,
}
pub struct JsAnalysis<'a> {
pub workspace: &'a Workspace,
pub graph: ModuleGraph,
pub exports: ExportUsage,
pub dependencies: DependencyUsage,
pub unused_files: Vec<UnusedFile>,
ambient_modules: HashSet<String>,
pub(crate) setup: Setup,
pub(crate) enabled_rules: Option<HashSet<String>>,
pub(crate) config_path: Option<PathBuf>,
}
fn collect_ambient_modules(
fs: &dyn FileSystem,
graph: &ModuleGraph,
candidates: &[Candidate],
sequential: bool,
) -> HashSet<String> {
let mut names: HashSet<String> = graph
.modules()
.iter()
.flat_map(|module| module.info.ambient_modules.iter().cloned())
.collect();
let declaration_files: Vec<&Path> = candidates
.iter()
.filter(|candidate| candidate.rel.ends_with(".d.ts") && graph.id_of(&candidate.abs).is_none())
.map(|candidate| candidate.abs.as_path())
.collect();
let options = ParseOptions {
in_file_references: false,
member_accesses: false,
};
let found = map_ordered(&declaration_files, sequential, |path| {
fs.read_to_string(path)
.ok()
.filter(|source| source.contains("declare module"))
.and_then(|source| parse_module(path, &source, &options).ok())
.map(|info| info.ambient_modules)
.unwrap_or_default()
});
names.extend(found.into_iter().flatten());
names
}
fn overlay(
fs: &Arc<dyn FileSystem>,
workspace: &Workspace,
config: &Config,
cache: &IndexCache,
) -> Option<Arc<dyn FileSystem>> {
let listing = match config.js.discovery {
JsDiscovery::Walk => return None,
JsDiscovery::Git => Listing::IndexOnly,
_ => Listing::VerifyDirectories,
};
indexed_fs(fs, &workspace.root, cache, listing)
}
#[must_use]
pub fn discovery_file_system(fs: &Arc<dyn FileSystem>, workspace: &Workspace, config: &Config) -> Arc<dyn FileSystem> {
overlay(fs, workspace, config, &IndexCache::default()).unwrap_or_else(|| Arc::clone(fs))
}
impl<'a> JsAnalysis<'a> {
#[must_use]
pub fn build(
workspace: &'a Workspace,
config: &Config,
fs: &Arc<dyn FileSystem>,
sources: &[Arc<dyn InputSource>],
sequential: bool,
) -> Self {
Self::build_cached(workspace, config, fs, sources, sequential, &IndexCache::default())
}
pub(crate) fn build_cached(
workspace: &'a Workspace,
config: &Config,
fs: &Arc<dyn FileSystem>,
sources: &[Arc<dyn InputSource>],
sequential: bool,
cache: &IndexCache,
) -> Self {
let phase = |name: &str| format!("js {name} {}", workspace.root.display());
let indexed = timing::phase(phase("git index"), || overlay(fs, workspace, config, cache));
let listed_from_index = indexed.is_some();
let fs = indexed.as_ref().unwrap_or(fs);
let mut setup = timing::phase(phase("setup (plugins, configs)"), || {
Setup::new(workspace, config, fs, sources, sequential)
});
let mut candidates = timing::phase(phase("discover"), || discover(fs.as_ref(), workspace, sequential));
if !setup.js_ignored.is_empty() {
candidates.retain(|candidate| !setup.js_ignored.contains(&candidate.owner));
}
let links = take_file_links(fs.as_ref(), &workspace.root, &mut candidates);
let found = timing::phase(phase("entries"), || {
entry_files(fs.as_ref(), workspace, &setup, &candidates)
});
setup.mark_entries_incomplete(&found.unmapped);
let entries = found.files;
let resolver = timing::phase(phase("resolver"), || {
ModuleResolver::new(Arc::clone(fs), workspace, &setup)
});
let inputs = Inputs {
fs: fs.as_ref(),
resolver: &resolver,
root: &workspace.root,
candidates: &candidates,
links: &links,
setup: &setup,
sequential,
};
let graph = timing::phase(phase("graph (read, parse, resolve)"), || {
graph::build(&inputs, &entries)
});
graph::report_timings(&workspace.root);
let exports = timing::phase(phase("exports"), || {
ExportUsage::compute_by(&graph, |owner| setup.includes_entry_exports(owner))
});
let dependencies = timing::phase(phase("dependencies"), || {
DependencyUsage::compute(workspace, &setup, &graph, fs.as_ref(), sequential)
});
let mut ambient_modules = collect_ambient_modules(fs.as_ref(), &graph, &candidates, sequential);
let packages: Vec<String> = ambient_modules
.iter()
.filter_map(|declared| package_name(declared))
.collect();
ambient_modules.extend(packages);
let unused_files = candidates
.iter()
.filter(|candidate| graph.id_of(&candidate.abs).is_none())
.filter(|candidate| !candidate.rel.ends_with(".d.ts"))
.filter(|candidate| {
let Some(package) = setup.package(candidate.owner) else {
return false;
};
if package.entries_incomplete {
return false;
}
let Some(in_package) = relative(&candidate.abs, &package.dir) else {
return false;
};
package.project.is_match(&in_package)
&& !package.ignore.matches_any(&in_package)
&& !setup.global_ignore.matches_any(&candidate.rel)
})
.filter(|candidate| !listed_from_index || fs.kind(&candidate.abs).is_file)
.map(|candidate| UnusedFile {
path: PathBuf::from(&candidate.rel),
owner: candidate.owner,
})
.collect();
Self {
workspace,
graph,
exports,
dependencies,
unused_files,
ambient_modules,
setup,
enabled_rules: None,
config_path: None,
}
}
#[must_use]
pub fn declares_module(&self, name: &str) -> bool {
self.ambient_modules.contains(name)
}
#[must_use]
pub fn production(&self) -> bool {
self.setup.production
}
#[must_use]
pub fn ignores_dependency(&self, id: PackageId, name: &str) -> bool {
self.setup.ignores_dependency(id, name)
}
#[must_use]
pub fn ignores_unresolved(&self, id: PackageId, specifier: &str) -> bool {
let listed = self.setup.ignores_unresolved(id, specifier);
self.setup.ignores_dependency(id, specifier) || listed
}
#[must_use]
pub fn ignores_member(&self, id: PackageId, name: &str) -> bool {
self.setup.ignores_member(id, name)
}
#[must_use]
pub fn entries_incomplete(&self, id: PackageId) -> bool {
self.setup.package(id).is_some_and(|package| package.entries_incomplete)
}
#[must_use]
pub fn is_skipped(&self, id: PackageId) -> bool {
self.setup.package(id).is_none()
}
#[must_use]
pub fn reports_exports_of(&self, module: &Module) -> bool {
module.in_project && !module.ignored && (!module.is_entry || self.setup.includes_entry_exports(module.owner))
}
#[must_use]
pub fn unused_exports<'m>(&self, module: &'m Module) -> Vec<&'m Export> {
if !self.reports_exports_of(module) {
return Vec::new();
}
module
.info
.exports
.iter()
.filter(|export| !(export.kind == ExportKind::StarReExport && export.name == "*"))
.filter(|export| export.name != "export=")
.filter(|export| !self.is_tagged_public(export))
.filter(|export| !(export.referenced_in_file && self.setup.ignores_in_file_use(module.owner, export)))
.filter(|export| !self.exports.is_used(module.id, &export.name))
.filter(|export| !self.in_used_signature(module, export, &mut HashSet::new()))
.collect()
}
pub(crate) fn in_used_signature(&self, module: &Module, export: &Export, visited: &mut HashSet<String>) -> bool {
export.referenced_in.iter().any(|name| {
if self.exports.is_used(module.id, name) {
return true;
}
visited.insert(name.clone())
&& module
.info
.exports
.iter()
.filter(|container| container.name == *name)
.any(|container| self.in_used_signature(module, container, visited))
})
}
pub(crate) fn is_tagged_public(&self, export: &Export) -> bool {
export.tags.iter().any(|tag| match tag.as_str() {
"public" | "beta" | "alpha" => true,
"internal" => self.setup.production,
other => self.setup.ignore_tags.iter().any(|ignored| ignored == other),
})
}
}