use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use indexmap::IndexMap;
use skif_config::{Config, JsDiscovery};
use skif_core::{FileSystem, IncompletePackage, Span, timing};
use skif_js_syntax::{Export, ExportKind, Member, ModuleCache, ParseOptions, parse_cached};
use skif_workspace::{PackageId, Workspace, map_ordered};
use crate::config_hints::{ConfigHint, HintInputs, config_hints};
use crate::deps::DependencyUsage;
use crate::discover::{Candidate, discover_with, relative, take_file_links};
use crate::entries::entry_files;
use crate::fixture::in_fixture_directory;
use crate::git_index::{IndexCache, Listing, indexed_fs};
use crate::globs::Globs;
use crate::graph::{self, Inputs, Module, ModuleGraph, Target};
use crate::input::InputSource;
use crate::resolve::ModuleResolver;
use crate::setup::{Setup, WorkspaceKeys};
use crate::specifier::package_name;
use crate::usage::ExportUsage;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnneededTag<'m> {
pub identifier: String,
pub span: Span,
pub tag: &'m str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EmptyPattern {
pub key: String,
pub pattern: String,
}
#[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) outside_graph: HashSet<String>,
pub(crate) setup: Setup,
pub(crate) enabled_rules: Option<HashSet<String>>,
pub(crate) config_path: Option<PathBuf>,
pub(crate) empty_patterns: Vec<EmptyPattern>,
pub(crate) unused_ignore_files: Vec<EmptyPattern>,
pub(crate) config_hints: Vec<ConfigHint>,
}
#[derive(Clone, Copy)]
pub(crate) struct Caches<'c> {
pub indexes: &'c IndexCache,
pub modules: Option<&'c dyn ModuleCache>,
pub extra_entries: &'c [PathBuf],
}
fn collect_ambient_modules(
fs: &dyn FileSystem,
graph: &ModuleGraph,
candidates: &[Candidate],
(sequential, modules): (bool, Option<&dyn ModuleCache>),
) -> 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_cached(modules, path, &source, &options).ok())
.map(|info| info.ambient_modules)
.unwrap_or_default()
});
names.extend(found.into_iter().flatten());
names
}
const UNWALKED_DIRS: [&str; 2] = ["node_modules", ".git"];
pub(crate) fn on_disk(fs: &dyn FileSystem, base: &Path, pattern: &str) -> bool {
let body = pattern.trim().trim_start_matches("./").trim_end_matches('!');
let globs = Globs::new([body]);
if globs.is_empty() {
return false;
}
let is_glob = |part: &str| part.contains(['*', '?', '[', '{']);
let parts: Vec<&str> = body.split('/').collect();
if !parts.iter().any(|part| is_glob(part)) {
return fs.is_file(&base.join(body));
}
let prefix: PathBuf = parts.iter().take_while(|part| !is_glob(part)).collect();
let mut stack = vec![base.join(prefix)];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs.read_dir_shared(&dir) else {
continue;
};
for entry in entries.iter() {
let Some(rel) = relative(&entry.path, base) else {
continue;
};
if entry.is_file && globs.matches_any(&rel) {
return true;
}
let unwalked = entry
.path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| UNWALKED_DIRS.contains(&name) && !parts.contains(&name));
if entry.is_dir && !entry.is_symlink && !unwalked {
stack.push(entry.path.clone());
}
}
}
false
}
fn key_dir(package: &skif_workspace::Package) -> String {
if package.is_root {
".".to_owned()
} else {
package.dir.to_string_lossy().replace('\\', "/")
}
}
pub(crate) fn workspace_key(
config: &Config,
keys: &WorkspaceKeys,
package: &skif_workspace::Package,
name: &str,
) -> String {
let dir = key_dir(package);
let written = keys
.key(&config.js.workspaces, &dir)
.map_or(dir.as_str(), String::as_str);
format!("js.workspaces.\"{written}\".{name}")
}
#[derive(Default)]
struct Judged {
matched: IndexMap<(String, String), bool>,
}
impl Judged {
fn judge(&mut self, key: String, pattern: &str, matches: impl FnOnce() -> bool) {
let slot = self.matched.entry((key, pattern.to_owned())).or_insert(false);
if !*slot {
*slot = matches();
}
}
fn empty(self) -> Vec<EmptyPattern> {
self.matched
.into_iter()
.filter(|(_, matched)| !matched)
.map(|((key, pattern), _)| EmptyPattern { key, pattern })
.collect()
}
}
fn empty_patterns(
workspace: &Workspace,
config: &Config,
setup: &Setup,
candidates: &[Candidate],
graph: &ModuleGraph,
disk: &dyn FileSystem,
) -> Vec<EmptyPattern> {
let files: Vec<(PackageId, &Path, String)> = candidates
.iter()
.map(|candidate| (candidate.owner, candidate.abs.as_path(), candidate.rel.clone()))
.chain(graph.modules().iter().map(|module| {
(
module.owner,
module.abs.as_path(),
module.path.to_string_lossy().replace('\\', "/"),
)
}))
.collect();
let judged = |pattern: &&String| {
let body = pattern.trim();
!body.is_empty() && !body.starts_with('!') && !body.starts_with("../")
};
let mut found = Judged::default();
for pattern in config.js.ignore.iter().filter(judged) {
found.judge("js.ignore".to_owned(), pattern, || {
let globs = Globs::new([pattern]);
files.iter().any(|(_, _, rel)| globs.matches_any(rel)) || on_disk(disk, &workspace.root, pattern)
});
}
let keys = WorkspaceKeys::new(&config.js.workspaces);
for (id, package) in workspace.iter() {
let Some(package_setup) = setup.package(id) else {
continue;
};
let owned: Vec<String> = files
.iter()
.filter(|(owner, _, _)| *owner == id)
.filter_map(|(_, abs, _)| relative(abs, &package_setup.dir))
.collect();
let user = &package_setup.user_patterns;
for (name, patterns, top_level) in [
("entry", &user.entry, &config.js.entry),
("project", &user.project, &config.js.project),
("ignore", &user.ignore, &config.js.ignore),
] {
for pattern in patterns.iter().filter(judged) {
let from_top_level = package.is_root && top_level.contains(pattern);
if from_top_level && name == "ignore" {
continue;
}
let key = if from_top_level {
format!("js.{name}")
} else {
workspace_key(config, &keys, package, name)
};
found.judge(key, pattern, || {
let globs = Globs::new([pattern]);
owned.iter().any(|rel| globs.matches_any(rel)) || on_disk(disk, &package_setup.dir, pattern)
});
}
}
}
found.empty()
}
fn unused_ignore_files(
workspace: &Workspace,
config: &Config,
setup: &Setup,
unreached: &[&Candidate],
disk: &dyn FileSystem,
) -> Vec<EmptyPattern> {
let mut found = Judged::default();
let all_judged = workspace
.iter()
.all(|(id, _)| setup.package(id).is_none_or(|package| !package.entries_incomplete));
for pattern in config.js.ignore_files.iter().filter(|_| all_judged) {
let globs = Globs::new([pattern]);
if globs.is_empty() {
continue;
}
found.judge("js.ignore-files".to_owned(), pattern, || {
unreached.iter().any(|candidate| globs.matches_any(&candidate.rel))
|| on_disk(disk, &workspace.root, pattern)
});
}
let keys = WorkspaceKeys::new(&config.js.workspaces);
let mut incomplete = HashSet::new();
for (id, package) in workspace.iter() {
let Some(package_setup) = setup.package(id) else {
continue;
};
let owned: Vec<String> = unreached
.iter()
.filter(|candidate| candidate.owner == id)
.filter_map(|candidate| relative(&candidate.abs, &package_setup.dir))
.collect();
for pattern in &package_setup.user_patterns.ignore_files {
let globs = Globs::new([pattern]);
if globs.is_empty() {
continue;
}
let key = workspace_key(config, &keys, package, "ignore-files");
if package_setup.entries_incomplete {
incomplete.insert((key, pattern.clone()));
continue;
}
found.judge(key, pattern, || {
owned.iter().any(|rel| globs.matches_any(rel)) || on_disk(disk, &package_setup.dir, pattern)
});
}
}
found.matched.retain(|judged, _| !incomplete.contains(judged));
found.empty()
}
fn overlay(
fs: &Arc<dyn FileSystem>,
workspace: &Workspace,
config: &Config,
cache: &IndexCache,
) -> Option<Arc<dyn FileSystem>> {
if config.js.no_gitignore {
return None;
}
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,
&Caches {
indexes: &IndexCache::default(),
modules: None,
extra_entries: &[],
},
)
}
pub(crate) fn build_cached(
workspace: &'a Workspace,
config: &Config,
fs: &Arc<dyn FileSystem>,
sources: &[Arc<dyn InputSource>],
sequential: bool,
caches: &Caches<'_>,
) -> Self {
let Caches {
indexes: cache,
modules,
extra_entries,
} = *caches;
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 disk = Arc::clone(fs);
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_with(fs.as_ref(), workspace, sequential, !config.js.no_gitignore)
});
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, extra_entries)
});
setup.mark_entries_incomplete(&found.unknown);
let entries = found.files;
let config_hints = {
let entry_set: HashSet<PathBuf> = entries.iter().cloned().collect();
config_hints(&HintInputs {
workspace,
config,
setup: &setup,
candidates: &candidates,
entries: &entry_set,
provided: &found.provided,
disk: disk.as_ref(),
})
};
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,
modules,
};
let graph = timing::phase(phase("graph (read, parse, resolve)"), || {
graph::build(&inputs, &entries)
});
graph::report_timings(&workspace.root);
setup.widen_incomplete_scopes(&graph);
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, modules));
let packages: Vec<String> = ambient_modules
.iter()
.filter_map(|declared| package_name(declared))
.collect();
ambient_modules.extend(packages);
let outside_graph: HashSet<String> = candidates
.iter()
.filter(|candidate| graph.id_of(&candidate.abs).is_none())
.map(|candidate| candidate.rel.clone())
.collect();
let unreached: Vec<&Candidate> = 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.unjudged_package_wide() {
return false;
}
let Some(in_package) = relative(&candidate.abs, &package.dir) else {
return false;
};
package.project.is_match(&in_package)
&& !package.in_incomplete_scope(&in_package)
&& !package.ignore.matches_any(&in_package)
&& !setup.global_ignore.matches_any(&candidate.rel)
&& (setup.include_fixtures || !in_fixture_directory(Path::new(&candidate.rel)))
})
.filter(|candidate| !listed_from_index || fs.kind(&candidate.abs).is_file)
.collect();
let ignores_file = |candidate: &Candidate| {
setup.global_ignore_files.matches_any(&candidate.rel)
|| setup.package(candidate.owner).is_some_and(|package| {
relative(&candidate.abs, &package.dir).is_some_and(|rel| package.ignore_files.matches_any(&rel))
})
};
let empty_patterns = empty_patterns(workspace, config, &setup, &candidates, &graph, disk.as_ref());
let unused_ignore_files = unused_ignore_files(workspace, config, &setup, &unreached, disk.as_ref());
let unused_files = unreached
.iter()
.filter(|candidate| !ignores_file(candidate))
.map(|candidate| UnusedFile {
path: PathBuf::from(&candidate.rel),
owner: candidate.owner,
})
.collect();
Self {
workspace,
graph,
exports,
dependencies,
unused_files,
ambient_modules,
outside_graph,
setup,
enabled_rules: None,
config_path: None,
empty_patterns,
unused_ignore_files,
config_hints,
}
}
#[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 strict(&self) -> bool {
self.setup.isolate_workspaces
}
#[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 other_project_files(&self) -> Vec<PathBuf> {
let mut files = BTreeSet::new();
for module in self.graph.modules().iter().filter(|module| !module.foreign) {
for import in &module.imports {
let targets = match &import.target {
Target::Module(id) => std::slice::from_ref(id),
Target::Modules(ids) => ids.as_slice(),
_ => &[],
};
for target in targets.iter().map(|id| self.graph.module(*id)) {
if target.foreign {
files.insert(target.path.clone());
}
}
}
}
files.into_iter().collect()
}
#[must_use]
pub fn incomplete_packages(&self) -> Vec<IncompletePackage> {
let root = &self.workspace.root;
self.workspace
.iter()
.filter_map(|(id, package)| {
let setup = self.setup.package(id).filter(|setup| setup.entries_incomplete)?;
let reasons = setup
.incomplete_reasons
.iter()
.cloned()
.map(|mut reason| {
reason.file = reason
.file
.map(|path| relative(&path, root).map_or(path, PathBuf::from));
reason
})
.collect();
Some(IncompletePackage::new(
package.display_name().to_owned(),
package.dir.clone(),
reasons,
))
})
.collect()
}
#[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))
&& !self.entries_incomplete(module.owner)
&& (self.setup.include_fixtures || !in_fixture_directory(&module.path))
}
#[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.setup.is_registered(module.owner, export))
.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| {
let registered = module
.info
.exports
.iter()
.any(|container| container.name == *name && self.setup.is_registered(module.owner, container));
if registered || 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 {
self.exempts_tags(&export.tags)
}
pub(crate) fn exempts_tags(&self, tags: &[String]) -> bool {
self.always_exempt(tags) || self.excluding_tags(tags).next().is_some() || self.lacks_included_tag(tags)
}
fn always_exempt(&self, tags: &[String]) -> bool {
tags.iter()
.any(|tag| matches!(tag.as_str(), "public" | "beta" | "alpha" | "alias") && !self.includes_tag(tag))
}
fn excluding_tags<'t>(&self, tags: &'t [String]) -> impl Iterator<Item = &'t str> {
tags.iter().map(String::as_str).filter(|tag| {
(*tag == "internal" && self.setup.production && !self.includes_tag(tag))
|| self.setup.ignore_tags.iter().any(|ignored| ignored == tag)
})
}
fn includes_tag(&self, tag: &str) -> bool {
self.setup.include_tags.iter().any(|included| included == tag)
}
fn lacks_included_tag(&self, tags: &[String]) -> bool {
!self.setup.include_tags.is_empty() && !tags.iter().any(|tag| self.includes_tag(tag))
}
pub(crate) fn members_of(export: &Export) -> &[Member] {
match export.kind {
ExportKind::Enum => &export.enum_members,
ExportKind::Namespace => &export.namespace_members,
_ => &[],
}
}
pub(crate) fn member_in_use(&self, module: &Module, export: &Export, member: &Member) -> bool {
self.ignores_member(module.owner, &member.name)
|| export.members_read_in_file.contains(&member.name)
|| self.exports.is_member_used(module.id, &export.name, &member.name)
}
fn export_in_use(&self, module: &Module, export: &Export) -> bool {
(export.referenced_in_file && self.setup.ignores_in_file_use(module.owner, export))
|| self.exports.is_used(module.id, &export.name)
|| self.in_used_signature(module, export, &mut HashSet::new())
}
#[must_use]
pub fn unneeded_tags<'m>(&self, module: &'m Module) -> Vec<UnneededTag<'m>> {
let mut found = Vec::new();
if !self.reports_exports_of(module) {
return found;
}
for export in &module.info.exports {
if self.always_exempt(&export.tags) || !self.export_in_use(module, export) {
continue;
}
let mut hides_member = false;
if self.exports.is_used(module.id, &export.name) {
for member in Self::members_of(export) {
if self.always_exempt(&member.tags) {
continue;
}
let mut excluding = self.excluding_tags(&member.tags).peekable();
if self.member_in_use(module, export, member) {
found.extend(excluding.map(|tag| UnneededTag {
identifier: format!("{}.{}", export.name, member.name),
span: member.span,
tag,
}));
} else if excluding.peek().is_none() && !self.lacks_included_tag(&member.tags) {
hides_member = true;
}
}
}
if !hides_member {
found.extend(self.excluding_tags(&export.tags).map(|tag| UnneededTag {
identifier: export.name.clone(),
span: export.span,
tag,
}));
}
}
found
}
}