use skif_core::{HashMap, HashSet};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use globset::{Glob, GlobBuilder, GlobMatcher};
use indexmap::IndexMap;
use skif_config::{Config, IgnoreExportsUsedInFile, IgnoreItem, JsWorkspace, PackageJs};
use skif_core::FileSystem;
use skif_js_plugins::scripts::is_machine_binary;
use skif_js_syntax::{Export, ExportKind, Registration};
use skif_workspace::{Package, PackageId, Workspace, map_ordered};
use crate::ancestors;
use crate::compilers::Compilers;
use crate::config_check::relative_tsconfig;
use crate::deps::member_dirs;
use crate::globs::{EntryRegexes, GlobCache, Globs, IncompleteScopeGlobs, Names};
use crate::graph::ModuleGraph;
use crate::input::{
BuildDir, ConfigDependency, CustomElementLibrary, IncompleteCause, IncompleteEntries, IncompleteScope,
InputRequest, InputSource, PathAlias, RunBinary, WorkspaceInputs, WorkspaceListing,
};
use crate::listed::{self, Listed};
use crate::resolve::join_normalized;
use crate::scripts::{self, RelocatedDependency, ScriptBinary, ScriptFacts};
use crate::tsconfig_files;
const EXTENSIONS: &str = "{js,mjs,cjs,jsx,ts,tsx,mts,cts}";
const PROJECT_EXTENSIONS: &str = "{js,mjs,cjs,jsx,ts,tsx,mts,cts,vue,svelte,astro}";
fn default_entry() -> Vec<String> {
vec![
format!("{{index,cli,main}}.{EXTENSIONS}"),
format!("src/{{index,cli,main}}.{EXTENSIONS}"),
]
}
fn ambient_declarations() -> Vec<String> {
vec!["**/*.d.{ts,mts,cts}".to_owned()]
}
fn test_directories() -> Vec<String> {
vec![
format!("{{test,tests,e2e,cypress}}/**/*.{EXTENSIONS}"),
format!("src/{{test,tests,e2e}}/**/*.{EXTENSIONS}"),
format!("**/__mocks__/**/*.{EXTENSIONS}"),
]
}
fn default_project(compilers: &Compilers) -> Vec<String> {
std::iter::once(format!("**/*.{PROJECT_EXTENSIONS}"))
.chain(compilers.extensions().map(|extension| format!("**/*.{extension}")))
.collect()
}
fn test_files() -> Vec<String> {
vec![
format!("**/*.{{test,spec}}.{EXTENSIONS}"),
format!("**/__tests__/**/*.{EXTENSIONS}"),
]
}
#[derive(Default)]
struct Configured {
entry: Vec<String>,
project: Vec<String>,
ignore: Vec<String>,
ignore_files: Vec<String>,
ignore_dependencies: Vec<String>,
ignore_binaries: Vec<String>,
ignore_unresolved: Vec<String>,
ignore_members: Vec<String>,
include_entry_exports: Option<bool>,
ignore_exports_used_in_file: Option<IgnoreExportsUsedInFile>,
paths: IndexMap<String, Vec<String>>,
}
fn names(items: &[IgnoreItem]) -> Vec<String> {
items.iter().map(|item| item.name.clone()).collect()
}
fn own_dir(package: &Package) -> String {
if package.is_root {
".".to_owned()
} else {
package.dir.to_string_lossy().replace('\\', "/")
}
}
fn normalize_dir(dir: &str) -> &str {
let dir = dir.trim_start_matches("./").trim_end_matches('/');
if dir.is_empty() { "." } else { dir }
}
fn anchored<'a>(anchor: Option<&str>, dir: &'a str) -> Cow<'a, str> {
let dir = normalize_dir(dir);
match anchor.map(normalize_dir) {
None | Some(".") => Cow::Borrowed(dir),
Some(anchor) if dir == "." => Cow::Owned(anchor.to_owned()),
Some(anchor) => Cow::Owned(format!("{anchor}/{dir}")),
}
}
impl Configured {
fn fold(&mut self, js: &PackageJs) {
let replace = |target: &mut Vec<String>, value: &Option<Vec<String>>| {
if let Some(value) = value {
target.clone_from(value);
}
};
let replace_names = |target: &mut Vec<String>, value: &Option<Vec<IgnoreItem>>| {
if let Some(value) = value {
*target = names(value);
}
};
replace(&mut self.entry, &js.entry);
replace(&mut self.project, &js.project);
replace(&mut self.ignore, &js.ignore);
replace_names(&mut self.ignore_dependencies, &js.ignore_dependencies);
replace_names(&mut self.ignore_binaries, &js.ignore_binaries);
replace_names(&mut self.ignore_unresolved, &js.ignore_unresolved);
self.ignore_unresolved.extend(names(&js.extend_ignore_unresolved));
self.ignore.extend(js.extend_ignore.iter().cloned());
self.ignore_dependencies.extend(names(&js.extend_ignore_dependencies));
self.ignore_binaries.extend(names(&js.extend_ignore_binaries));
}
}
impl From<&JsWorkspace> for Configured {
fn from(workspace: &JsWorkspace) -> Self {
Self {
entry: workspace.entry.clone(),
project: workspace.project.clone(),
ignore: workspace.ignore.clone(),
ignore_files: names(&workspace.ignore_files),
ignore_dependencies: names(&workspace.ignore_dependencies),
ignore_binaries: names(&workspace.ignore_binaries),
ignore_unresolved: names(&workspace.ignore_unresolved),
ignore_members: names(&workspace.ignore_members),
include_entry_exports: workspace.include_entry_exports,
ignore_exports_used_in_file: workspace.ignore_exports_used_in_file.clone(),
paths: workspace.paths.clone(),
}
}
}
impl Configured {
fn fold_root(&mut self, root: Configured) {
let replace = |target: &mut Vec<String>, value: Vec<String>| {
if !value.is_empty() {
*target = value;
}
};
replace(&mut self.entry, root.entry);
replace(&mut self.project, root.project);
self.ignore.extend(root.ignore);
self.ignore_files.extend(root.ignore_files);
self.ignore_dependencies.extend(root.ignore_dependencies);
self.ignore_binaries.extend(root.ignore_binaries);
self.ignore_unresolved.extend(root.ignore_unresolved);
self.ignore_members.extend(root.ignore_members);
self.include_entry_exports = root.include_entry_exports.or(self.include_entry_exports);
self.ignore_exports_used_in_file = root
.ignore_exports_used_in_file
.or(self.ignore_exports_used_in_file.take());
self.paths.extend(root.paths);
}
}
fn configured(
config: &Config,
keys: &WorkspaceKeys,
name: &str,
abs_dir: &Path,
dir: &str,
is_root: bool,
) -> Configured {
let js = &config.js;
let mut user = if is_root {
let mut root = Configured {
entry: js.entry.clone(),
project: js.project.clone(),
ignore: js.ignore.clone(),
..Configured::default()
};
if let Some(table) = keys.table(&js.workspaces, ".") {
root.fold_root(Configured::from(table));
}
root
} else {
keys.table(&js.workspaces, dir).map_or_default(Configured::from)
};
for scoped in config
.overrides
.iter()
.filter(|scoped| !scoped.js.is_empty() && scoped.applies_to(Some((name, abs_dir)), None))
{
user.fold(&scoped.js);
}
user
}
#[derive(Debug, Clone, Default)]
pub(crate) struct WorkspaceKeys {
keys: Vec<WorkspaceKey>,
anchor: Option<String>,
}
#[derive(Debug, Clone)]
struct WorkspaceKey {
dir: String,
glob: Option<GlobMatcher>,
}
impl WorkspaceKey {
fn selects(&self, dir: &str) -> bool {
self.dir == dir || (dir != "." && self.glob.as_ref().is_some_and(|glob| glob.is_match(dir)))
}
}
impl WorkspaceKeys {
pub(crate) fn new(workspaces: &IndexMap<String, JsWorkspace>) -> Self {
let keys = workspaces
.keys()
.map(|key| {
let dir = normalize_dir(key);
let glob = dir
.contains(['*', '?', '[', '{'])
.then(|| GlobBuilder::new(dir).literal_separator(true).build().ok())
.flatten()
.map(|glob| glob.compile_matcher());
WorkspaceKey {
dir: dir.to_owned(),
glob,
}
})
.collect();
Self { keys, anchor: None }
}
#[must_use]
pub(crate) fn with_anchor(mut self, anchor: Option<&str>) -> Self {
self.anchor = anchor.map(str::to_owned);
self
}
pub(crate) fn key_dir(&self, package: &Package) -> String {
anchored(self.anchor.as_deref(), &own_dir(package)).into_owned()
}
pub(crate) fn anchored_dir<'d>(&self, dir: &'d str) -> Cow<'d, str> {
anchored(self.anchor.as_deref(), dir)
}
pub(crate) fn package_key<'a>(
&self,
workspaces: &'a IndexMap<String, JsWorkspace>,
package: &Package,
) -> Option<&'a String> {
self.key(workspaces, &own_dir(package))
}
pub(crate) fn table<'a>(
&self,
workspaces: &'a IndexMap<String, JsWorkspace>,
dir: &str,
) -> Option<&'a JsWorkspace> {
self.entry(workspaces, dir).map(|(_, workspace)| workspace)
}
pub(crate) fn key<'a>(&self, workspaces: &'a IndexMap<String, JsWorkspace>, dir: &str) -> Option<&'a String> {
self.entry(workspaces, dir).map(|(key, _)| key)
}
fn entry<'a>(
&self,
workspaces: &'a IndexMap<String, JsWorkspace>,
dir: &str,
) -> Option<(&'a String, &'a JsWorkspace)> {
let dir = anchored(self.anchor.as_deref(), dir);
let dir: &str = &dir;
let position = self
.keys
.iter()
.position(|key| key.dir == dir)
.or_else(|| self.keys.iter().position(|key| key.selects(dir)))?;
workspaces.get_index(position)
}
fn unmatched<'a>(&self, workspaces: &'a IndexMap<String, JsWorkspace>, dirs: &[String]) -> Vec<&'a String> {
workspaces
.keys()
.zip(&self.keys)
.filter(|(_, key)| {
!dirs
.iter()
.any(|dir| key.selects(&anchored(self.anchor.as_deref(), dir)))
})
.map(|(text, _)| text)
.collect()
}
}
pub(crate) fn ignores_in_file_use(setting: &IgnoreExportsUsedInFile, export: &Export) -> bool {
match setting {
IgnoreExportsUsedInFile::All(all) => *all,
IgnoreExportsUsedInFile::Kinds(kinds) => match export.kind {
ExportKind::Value => kinds.variable,
ExportKind::Function => kinds.function,
ExportKind::Class => kinds.class,
ExportKind::Enum => kinds.enumeration,
ExportKind::Namespace => kinds.namespace,
ExportKind::Type => kinds.type_alias || kinds.interface,
_ => false,
},
_ => false,
}
}
pub(crate) struct PackageSetup {
pub id: PackageId,
pub dir: PathBuf,
pub table_key: Option<String>,
pub entry: Globs,
pub external_entry: Globs,
pub shared_entry: Globs,
pub plugin_entry: Globs,
pub plugin_entry_files: Vec<PathBuf>,
pub build_source: Globs,
pub shared_build_source: Globs,
pub build_source_files: Vec<PathBuf>,
pub entries_incomplete: bool,
pub incomplete_reasons: Vec<IncompleteEntries>,
pub incomplete_scope: Option<IncompleteScopeGlobs>,
pub widened_by: Option<(PathBuf, u32)>,
pub project: Globs,
pub ignore: Globs,
pub ignore_files: Globs,
pub ignore_dependencies: Names,
pub ignore_binaries: Names,
pub user_ignore_dependencies: Listed,
pub user_ignore_binaries: Listed,
pub ignore_unresolved: Listed,
pub ignore_members: Listed,
pub user_patterns: UserPatterns,
pub include_entry_exports: bool,
pub used_in_file: IgnoreExportsUsedInFile,
pub referenced_dependencies: Vec<String>,
pub config_dependencies: Vec<ConfigDependency>,
pub referenced_binaries: Vec<String>,
pub run_binaries: Vec<RunBinary>,
pub scripts: ScriptFacts,
pub config_files: Vec<PathBuf>,
pub aliases: Vec<PathAlias>,
pub paths: IndexMap<String, Vec<String>>,
pub build_dirs: Vec<BuildDir>,
pub conditions: Vec<String>,
pub module_dirs: Vec<String>,
pub extensions: Vec<String>,
pub custom_elements: Vec<CustomElementLibrary>,
}
impl PackageSetup {
pub(crate) fn in_incomplete_scope(&self, rel: &str) -> bool {
self.entries_incomplete && self.incomplete_scope.as_ref().is_some_and(|scope| scope.is_match(rel))
}
pub(crate) fn unjudged_package_wide(&self) -> bool {
self.entries_incomplete && (self.incomplete_scope.is_none() || self.widened_by.is_some())
}
pub(crate) fn judges(&self, rel: &str) -> bool {
!self.unjudged_package_wide() && !self.in_incomplete_scope(rel)
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct UserPatterns {
pub entry: Vec<String>,
pub project: Vec<String>,
pub ignore: Vec<String>,
pub ignore_files: Vec<String>,
}
#[allow(
clippy::struct_excessive_bools,
reason = "each bool is an independent switch of the analysis"
)]
pub(crate) struct Setup {
pub packages: Vec<Option<PackageSetup>>,
pub git_root: Option<PathBuf>,
pub global_ignore: Globs,
pub global_ignore_files: Globs,
pub global_ignore_dependencies: Listed,
pub global_ignore_binaries: Listed,
pub global_ignore_unresolved: Listed,
pub global_ignore_members: Listed,
pub ignore_workspaces: Listed,
pub js_ignored: HashSet<PackageId>,
pub workspace_key_verdicts: Vec<(String, bool)>,
pub workspace_keys: WorkspaceKeys,
pub production: bool,
pub root_tsconfig: Option<PathBuf>,
pub isolate_workspaces: bool,
pub strict: bool,
pub include_entry_exports: bool,
pub include_fixtures: bool,
pub used_in_file: IgnoreExportsUsedInFile,
pub ignore_tags: Vec<String>,
pub include_tags: Vec<String>,
pub cycles: skif_config::JsCycles,
pub ignore_global_binaries: bool,
pub use_public_hoisting: bool,
pub gitignore: bool,
pub compilers: Arc<Compilers>,
}
pub(crate) fn tsconfig_path(root_tsconfig: Option<&Path>, dir: &Path, is_root: bool) -> PathBuf {
match root_tsconfig {
Some(config) if is_root => config.to_path_buf(),
_ => dir.join("tsconfig.json"),
}
}
fn workspace_relative(dir: &str, pattern: &str) -> Option<String> {
let (negation, body) = pattern.strip_prefix('!').map_or(("", pattern), |rest| ("!", rest));
let mut parts: Vec<&str> = Vec::new();
for segment in dir.split('/').chain(body.split('/')) {
match segment {
"" | "." => {}
".." => {
parts.pop()?;
}
other => parts.push(other),
}
}
Some(format!("{negation}{}", parts.join("/")))
}
struct WorkspaceTarget<'a> {
name: Option<&'a str>,
dir: Option<&'a str>,
pattern: Option<GlobMatcher>,
}
impl<'a> WorkspaceTarget<'a> {
fn new(target: &'a str) -> Self {
let target = target.trim_start_matches("...").trim_end_matches("...");
if target.starts_with('!') {
return Self {
name: None,
dir: None,
pattern: None,
};
}
let is_path = target.starts_with('.') || (target.contains('/') && !target.starts_with('@'));
Self {
name: Some(target),
dir: is_path.then(|| normalize_dir(target)),
pattern: target
.contains(['*', '?', '[', '{'])
.then(|| Glob::new(target).ok())
.flatten()
.map(|glob| glob.compile_matcher()),
}
}
fn names(&self, package: &Package, dir: &str) -> bool {
let Some(target) = self.name else {
return false;
};
let name = package.name.as_deref();
name == Some(target)
|| self.dir == Some(dir)
|| name.is_some_and(|name| self.pattern.as_ref().is_some_and(|pattern| pattern.is_match(name)))
}
}
fn forward_workspace_binaries(packages: &mut [Option<PackageSetup>], workspace: &Workspace) {
let mut credits: Vec<(PackageId, ScriptBinary)> = Vec::new();
let dirs = package_dirs(workspace);
for setup in packages.iter().flatten() {
for forwarded in &setup.scripts.forwarded {
let targets: Vec<WorkspaceTarget<'_>> = forwarded
.targets
.iter()
.map(|target| WorkspaceTarget::new(target))
.collect();
for ((id, target), (_, dir)) in workspace.iter().zip(&dirs) {
let runs_script = target
.manifest
.value()
.get("scripts")
.and_then(|scripts| scripts.get(&forwarded.binary.name))
.is_some();
if !runs_script && targets.iter().any(|selector| selector.names(target, dir)) {
credits.push((id, forwarded.binary.clone()));
}
}
}
}
for (id, binary) in credits {
if let Some(Some(setup)) = packages.get_mut(id.index())
&& !setup.scripts.binaries.contains(&binary)
&& !setup.scripts.ephemeral_binaries.contains(&binary)
{
setup.scripts.ephemeral_binaries.push(binary);
}
}
}
fn relocate_script_commands(packages: &mut [Option<PackageSetup>], workspace: &Workspace) {
let dirs = package_dirs(workspace);
let owner = |from: PackageId, dir: &str| package_in_dir(&dirs, from, dir);
let mut credits: Vec<(PackageId, ScriptBinary)> = Vec::new();
let mut dependencies: Vec<(PackageId, String)> = Vec::new();
for setup in packages.iter_mut().flatten() {
let from = setup.id;
let (ephemeral, judged): (Vec<_>, Vec<_>) = std::mem::take(&mut setup.scripts.relocated_binaries)
.into_iter()
.partition(|relocated| relocated.ephemeral);
setup.scripts.relocated_binaries = judged;
for relocated in ephemeral {
let target = owner(from, &relocated.dir);
if target != from {
credits.push((target, relocated.binary));
} else if !relocated.filtered && !setup.scripts.ephemeral_binaries.contains(&relocated.binary) {
setup.scripts.ephemeral_binaries.push(relocated.binary);
}
}
for relocated in std::mem::take(&mut setup.scripts.relocated_dependencies) {
dependencies.push((owner(from, &relocated.dir), relocated.name));
}
}
for (id, binary) in credits {
if let Some(Some(setup)) = packages.get_mut(id.index())
&& !setup.scripts.binaries.contains(&binary)
&& !setup.scripts.ephemeral_binaries.contains(&binary)
{
setup.scripts.ephemeral_binaries.push(binary);
}
}
for (id, name) in dependencies {
if let Some(Some(setup)) = packages.get_mut(id.index())
&& !setup.referenced_dependencies.contains(&name)
{
setup.referenced_dependencies.push(name);
}
}
}
pub(crate) fn package_dirs(workspace: &Workspace) -> Vec<(PackageId, String)> {
workspace
.iter()
.map(|(id, package)| (id, package.dir.to_string_lossy().replace('\\', "/")))
.collect()
}
pub(crate) fn package_in_dir(dirs: &[(PackageId, String)], from: PackageId, dir: &str) -> PackageId {
placed_dir(dirs, from, dir).map_or(from, |(id, _)| id)
}
pub(crate) fn placed_dir(dirs: &[(PackageId, String)], from: PackageId, dir: &str) -> Option<(PackageId, String)> {
let base = dirs
.iter()
.find(|(id, _)| *id == from)
.map_or("", |(_, base)| base.as_str());
let target = join_dir(base, dir)?;
let id = dirs
.iter()
.filter(|(_, candidate)| contains_dir(candidate, &target))
.max_by_key(|(_, candidate)| candidate.len())
.map_or(from, |(id, _)| *id);
Some((id, target))
}
fn join_dir(base: &str, dir: &str) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
for part in base.split('/').chain(dir.split('/')) {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
other => parts.push(other),
}
}
Some(parts.join("/"))
}
fn contains_dir(ancestor: &str, dir: &str) -> bool {
ancestor.is_empty()
|| dir
.strip_prefix(ancestor)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
fn is_shared_pattern(pattern: &str) -> bool {
let body = pattern.trim_start_matches('!').trim_start_matches("./");
!body.starts_with("**/") && !body.starts_with("../") && !body.starts_with('/')
}
fn is_literal_path(pattern: &str) -> bool {
!pattern.is_empty() && !pattern.contains(['*', '?', '[', ']', '{', '}', '!'])
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub(crate) struct UnusedExportsOptions {
pub ignore_tags: Vec<String>,
pub include_tags: Vec<String>,
}
impl Setup {
pub(crate) fn new(
workspace: &Workspace,
config: &Config,
fs: &Arc<dyn FileSystem>,
sources: &[Arc<dyn InputSource>],
sequential: bool,
) -> Self {
let js = &config.js;
let production = js.production_mode();
let git_root = ancestors::repository_root(fs.as_ref(), &workspace.root);
let ancestor_dependencies = ancestors::external_dependencies(fs.as_ref(), &workspace.root, git_root.as_deref());
let UnusedExportsOptions {
ignore_tags,
include_tags,
} = config
.rule("unused-exports")
.and_then(|rule| skif_lint::parse_options::<UnusedExportsOptions>(&rule.options).ok())
.unwrap_or_default();
let ignore_workspaces = Listed::directories(&js.ignore_workspaces, production);
let workspace_keys = WorkspaceKeys::new(&js.workspaces).with_anchor(workspace.workspace_keys_anchor());
let js_ignored: HashSet<PackageId> = workspace
.iter()
.filter(|(_, package)| !package.is_root)
.filter(|(_, package)| ignore_workspaces.is_match(&workspace_keys.key_dir(package)))
.map(|(id, _)| id)
.collect();
let package_dirs: Vec<String> = workspace
.iter()
.map(|(_, package)| {
if package.is_root {
".".to_owned()
} else {
package.dir.to_string_lossy().replace('\\', "/")
}
})
.collect();
let unmatched = workspace_keys.unmatched(&js.workspaces, &package_dirs);
let workspace_key_verdicts: Vec<(String, bool)> = js
.workspaces
.keys()
.map(|key| (key.clone(), unmatched.contains(&key)))
.collect();
let root_tsconfig = js
.tsconfig
.as_deref()
.and_then(relative_tsconfig)
.map(|path| join_normalized(&workspace.root, &path));
let named_members = if js.use_tsconfig_files {
member_dirs(workspace)
} else {
Vec::new()
};
let compilers = Arc::new(Compilers::new(&js.compilers));
let project_defaults = default_project(&compilers);
let listing = WorkspaceListing::new(!js.no_gitignore, sequential);
let members: Vec<(PackageId, &Package)> = workspace.iter().collect();
let globs = GlobCache::default();
let mut packages: Vec<Option<PackageSetup>> = map_ordered(&members, sequential, |&(id, package)| {
if workspace.is_ignored(id) || js_ignored.contains(&id) {
return None;
}
let dir_name = package.dir.to_string_lossy().replace('\\', "/");
let abs_dir = workspace.root.join(&package.dir);
let match_dir = if package.is_root {
workspace.root.as_path()
} else {
abs_dir.as_path()
};
let user = configured(
config,
&workspace_keys,
package.display_name(),
match_dir,
&dir_name,
package.is_root,
);
let mut plugin = WorkspaceInputs::default();
let workspace_key = workspace_keys.key_dir(package);
let table_key = workspace_keys.package_key(&js.workspaces, package).cloned();
let request = InputRequest {
workspace,
package,
fs: fs.as_ref(),
ancestor_dependencies: &ancestor_dependencies,
workspace_key: &workspace_key,
listing: &listing,
};
for source in sources {
plugin.extend(source.inputs(&request));
}
let entry_regex = if production {
None
} else {
EntryRegexes::new(&plugin.entry_regex, &plugin.scoped_entry_regex)
};
let mut scripts = scripts::analyze(package);
scripts.relocated_dependencies.extend(
std::mem::take(&mut plugin.relocated_dependencies)
.into_iter()
.map(|(dir, name)| RelocatedDependency { dir, name }),
);
let mut entry = listed::user_globs(&user.entry, production, &default_entry());
entry.extend(ambient_declarations());
let mut shared_sources: Vec<&String> = plugin.production_entry.iter().collect();
if !production {
shared_sources.extend(plugin.entry.iter());
}
let shared_entry: Vec<String> = shared_sources
.into_iter()
.filter(|pattern| is_shared_pattern(pattern))
.cloned()
.collect();
let development_entry = if production {
listed::non_production_entries(&plugin.entry)
} else {
Vec::new()
};
let mut plugin_entry: Vec<String> = plugin.production_entry.clone();
if !production {
plugin_entry.extend(plugin.entry.iter().cloned());
entry.extend(plugin.entry);
}
entry.extend(plugin.production_entry);
let plugin_entry_files: Vec<PathBuf> = plugin_entry
.iter()
.filter(|pattern| is_literal_path(pattern))
.map(|pattern| join_normalized(&abs_dir, pattern))
.collect();
plugin_entry.retain(|pattern| !pattern.trim_start_matches('!').starts_with("../"));
let mut build_source = plugin.production_build_source;
if !production {
build_source.extend(plugin.build_source);
}
let shared_build_source: Vec<String> = build_source
.iter()
.filter(|pattern| is_shared_pattern(pattern))
.cloned()
.collect();
let build_source_files: Vec<PathBuf> = build_source
.iter()
.filter(|pattern| is_literal_path(pattern))
.map(|pattern| join_normalized(&abs_dir, pattern))
.collect();
let tsconfig_project = js
.use_tsconfig_files
.then(|| {
let config = tsconfig_path(root_tsconfig.as_deref(), &abs_dir, package.is_root);
tsconfig_files::project_globs(fs.as_ref(), &named_members, &config, &abs_dir)
})
.flatten();
let from_tsconfig = tsconfig_project.is_some();
let (mut project, mut named_files) = match tsconfig_project {
Some(selected) => (selected.patterns, selected.files),
None => (
listed::user_globs(&user.project, production, &project_defaults),
Vec::new(),
),
};
if production {
let mut exclusions = listed::production_exclusions(&plugin.project);
exclusions.extend(listed::non_production_entries(&user.entry));
exclusions.extend(development_entry);
exclusions.extend(
test_files()
.into_iter()
.chain(test_directories())
.map(|glob| format!("!{glob}")),
);
if !named_files.is_empty() {
named_files.extend(exclusions.iter().cloned());
}
project.extend(exclusions);
} else if !from_tsconfig {
project.extend(plugin.project);
}
let user_patterns = UserPatterns {
entry: user.entry.clone(),
project: user.project.clone(),
ignore: user.ignore.clone(),
ignore_files: user.ignore_files.clone(),
};
let mut ignore = user.ignore;
ignore.extend(plugin.ignore);
let mut ignore_dependencies = plugin.ignore_dependencies;
ignore_dependencies.extend(plugin.virtual_modules);
let ignore_binaries = plugin.ignore_binaries;
let mut referenced_dependencies = plugin.dependencies;
referenced_dependencies.extend(scripts.dependencies.iter().cloned());
let (outside, entry): (Vec<String>, Vec<String>) = entry
.into_iter()
.partition(|pattern| pattern.trim_start_matches('!').starts_with("../"));
let external_entry = Globs::new(
outside
.iter()
.filter_map(|pattern| workspace_relative(&dir_name, pattern)),
);
Some(PackageSetup {
id,
dir: abs_dir,
table_key,
entry: globs.globs(&entry).with_regexes(entry_regex.clone()),
external_entry,
shared_entry: globs.globs(&shared_entry),
plugin_entry: globs.globs(&plugin_entry).with_regexes(entry_regex),
plugin_entry_files,
build_source: globs.globs(&build_source),
shared_build_source: globs.globs(&shared_build_source),
build_source_files,
entries_incomplete: plugin.entries_incomplete,
incomplete_scope: match &plugin.incomplete_scope {
IncompleteScope::Within(patterns) if plugin.entries_incomplete => {
Some(IncompleteScopeGlobs::new(patterns.clone()))
}
_ => None,
},
widened_by: None,
incomplete_reasons: if plugin.entries_incomplete && plugin.incomplete_reasons.is_empty() {
vec![IncompleteEntries::new(IncompleteCause::Unspecified)]
} else {
plugin.incomplete_reasons
},
project: globs.globs(&project).or(Globs::new(named_files)),
ignore: globs.globs(&ignore),
ignore_files: globs.globs(&user.ignore_files),
ignore_dependencies: Names::new(ignore_dependencies),
ignore_binaries: Names::new(ignore_binaries),
user_ignore_dependencies: Listed::new(&user.ignore_dependencies, production),
user_ignore_binaries: Listed::new(&user.ignore_binaries, production),
ignore_unresolved: Listed::new(&user.ignore_unresolved, production),
ignore_members: Listed::new(&user.ignore_members, production),
user_patterns,
include_entry_exports: user.include_entry_exports.unwrap_or(js.include_entry_exports),
used_in_file: user
.ignore_exports_used_in_file
.unwrap_or_else(|| js.ignore_exports_used_in_file.clone()),
referenced_dependencies,
config_dependencies: plugin.config_dependencies,
referenced_binaries: plugin.binaries,
run_binaries: plugin.run_binaries,
scripts,
config_files: plugin.config_files,
aliases: plugin.aliases,
paths: user.paths.clone(),
build_dirs: plugin.build_dirs,
conditions: plugin.conditions,
module_dirs: plugin.module_dirs,
extensions: plugin.extensions,
custom_elements: plugin.custom_elements,
})
});
forward_workspace_binaries(&mut packages, workspace);
relocate_script_commands(&mut packages, workspace);
Self {
packages,
git_root,
global_ignore: Globs::new(&js.ignore),
global_ignore_files: Globs::new(&js.ignore_files),
global_ignore_dependencies: Listed::new(&js.ignore_dependencies, production),
global_ignore_binaries: Listed::new(&js.ignore_binaries, production),
global_ignore_unresolved: Listed::new(&js.ignore_unresolved, production),
global_ignore_members: Listed::new(&js.ignore_members, production),
ignore_workspaces,
js_ignored,
workspace_key_verdicts,
workspace_keys,
production,
root_tsconfig,
isolate_workspaces: js.isolated(),
strict: js.strict,
include_entry_exports: js.include_entry_exports,
include_fixtures: js.include_fixtures,
used_in_file: js.ignore_exports_used_in_file.clone(),
ignore_tags,
include_tags,
cycles: js.cycles.clone(),
ignore_global_binaries: js.ignore_global_binaries,
use_public_hoisting: js.use_public_hoisting,
gitignore: !js.no_gitignore,
compilers,
}
}
pub(crate) fn in_ignored_directory(
&self,
rel: &str,
owner_dir: &str,
verdicts: &mut HashMap<String, bool>,
) -> bool {
rel.match_indices('/')
.map(|(end, _)| &rel[..end])
.filter(|dir| dir.len() > owner_dir.len())
.any(|dir| {
*verdicts
.entry(dir.to_owned())
.or_insert_with(|| self.ignore_workspaces.is_match(&self.workspace_keys.anchored_dir(dir)))
})
}
pub(crate) fn tsconfig_of(&self, workspace: &Workspace, package: &Package) -> PathBuf {
tsconfig_path(
self.root_tsconfig.as_deref(),
&workspace.root.join(&package.dir),
package.is_root,
)
}
pub(crate) fn package(&self, id: PackageId) -> Option<&PackageSetup> {
self.packages.get(id.index()).and_then(Option::as_ref)
}
pub(crate) fn widen_incomplete_scopes(&mut self, graph: &ModuleGraph) {
for module in graph
.modules()
.iter()
.filter(|module| module.in_project && !module.foreign)
{
let Some(span) = module.info.unresolved_dynamic.first() else {
continue;
};
if let Some(Some(package)) = self.packages.get_mut(module.owner.index())
&& package.incomplete_scope.is_some()
&& package.widened_by.is_none()
{
package.widened_by = Some((module.path.clone(), span.start_line));
}
}
}
pub(crate) fn mark_entries_incomplete(&mut self, unknown: &[(PackageId, IncompleteCause)]) {
for &(id, cause) in unknown {
if let Some(Some(package)) = self.packages.get_mut(id.index()) {
package.entries_incomplete = true;
package.incomplete_scope = None;
package
.incomplete_reasons
.push(IncompleteEntries::new(cause).with_file(package.dir.join("package.json")));
}
}
}
pub(crate) fn ignores_dependency(&self, id: PackageId, name: &str) -> bool {
let global = self.global_ignore_dependencies.is_match(name);
let package = self.package(id).is_some_and(|setup| {
let user = setup.user_ignore_dependencies.is_match(name);
setup.ignore_dependencies.is_match(name) || user
});
global || package
}
pub(crate) fn ignores_binary(&self, id: PackageId, name: &str) -> bool {
let global = self.global_ignore_binaries.is_match(name);
let package = self.package(id).is_some_and(|setup| {
let user = setup.user_ignore_binaries.is_match(name);
setup.ignore_binaries.is_match(name) || user
});
global || package || (self.ignore_global_binaries && is_machine_binary(name))
}
pub(crate) fn ignores_unresolved(&self, id: PackageId, specifier: &str) -> bool {
let global = self.global_ignore_unresolved.is_match(specifier);
let package = self
.package(id)
.is_some_and(|setup| setup.ignore_unresolved.is_match(specifier));
global || package
}
pub(crate) fn ignores_member(&self, id: PackageId, name: &str) -> bool {
self.global_ignore_members.is_match(name)
|| self
.package(id)
.is_some_and(|setup| setup.ignore_members.is_match(name))
}
pub(crate) fn includes_entry_exports(&self, id: PackageId) -> bool {
self.package(id)
.map_or(self.include_entry_exports, |setup| setup.include_entry_exports)
}
pub(crate) fn ignores_in_file_use(&self, id: PackageId, export: &Export) -> bool {
let setting = self.package(id).map_or(&self.used_in_file, |setup| &setup.used_in_file);
ignores_in_file_use(setting, export)
}
pub(crate) fn is_registered(&self, id: PackageId, export: &Export) -> bool {
let libraries = self
.package(id)
.map_or(&[][..], |setup| setup.custom_elements.as_slice());
export.registrations.iter().any(|registration| match registration {
Registration::Define => true,
Registration::Decorator { module, name } => libraries
.iter()
.any(|library| library.decorator == *name && library.is_module(module)),
Registration::BaseClassDefine { module, name } => libraries
.iter()
.any(|library| library.base_class.as_deref() == Some(name.as_str()) && library.is_module(module)),
})
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use indexmap::IndexMap;
use skif_config::{Config, JsWorkspace};
use skif_core::MemoryFs;
use skif_workspace::Workspace;
use super::{WorkspaceKeys, WorkspaceTarget, anchored, is_shared_pattern, package_dirs, workspace_relative};
fn tables(keys: &[&str]) -> IndexMap<String, JsWorkspace> {
keys.iter()
.map(|key| ((*key).to_owned(), JsWorkspace::default()))
.collect()
}
#[test]
fn package_directories_are_joined_to_the_anchor() {
for (anchor, dir, expected) in [
(None, "packages/a", "packages/a"),
(None, "", "."),
(Some("."), "packages/a", "packages/a"),
(Some("tools/sub"), ".", "tools/sub"),
(Some("tools/sub"), "", "tools/sub"),
(Some("./tools/sub/"), "packages/a", "tools/sub/packages/a"),
] {
assert_eq!(anchored(anchor, dir), expected, "{anchor:?} {dir}");
}
}
#[test]
fn without_an_anchor_keys_select_from_the_root() {
let workspaces = tables(&[".", "tools/sub", "packages/*"]);
let keys = WorkspaceKeys::new(&workspaces);
assert_eq!(keys.key(&workspaces, ".").map(String::as_str), Some("."));
assert_eq!(
keys.key(&workspaces, "packages/a").map(String::as_str),
Some("packages/*")
);
assert_eq!(
keys.unmatched(&workspaces, &[".".to_owned()]),
["tools/sub", "packages/*"]
);
}
#[test]
fn anchored_keys_select_the_directories_they_name_below_the_start() {
let workspaces = tables(&[".", "tools/sub", "tools/sub/packages/*", "tools/*", "apps/*"]);
let keys = WorkspaceKeys::new(&workspaces).with_anchor(Some("tools/sub"));
assert_eq!(keys.key(&workspaces, ".").map(String::as_str), Some("tools/sub"));
assert_eq!(keys.key(&workspaces, "").map(String::as_str), Some("tools/sub"));
assert_eq!(
keys.key(&workspaces, "packages/a").map(String::as_str),
Some("tools/sub/packages/*")
);
assert_eq!(keys.key(&workspaces, "apps/web"), None);
assert_eq!(
keys.unmatched(&workspaces, &[".".to_owned(), "packages/a".to_owned()]),
[".", "apps/*"]
);
}
#[test]
fn only_rooted_plugin_globs_are_shared_with_nested_packages() {
assert!(is_shared_pattern("apps/web/playwright/**/*.ts"));
assert!(is_shared_pattern("!apps/web/legacy/**"));
assert!(!is_shared_pattern("**/*.test.ts"));
assert!(!is_shared_pattern("../shared/setup.ts"));
}
#[test]
fn patterns_outside_the_package_become_workspace_relative() {
assert_eq!(
workspace_relative("apps/a", "../../shared/setup.ts").as_deref(),
Some("shared/setup.ts")
);
assert_eq!(workspace_relative("a", "!../b/**").as_deref(), Some("!b/**"));
assert_eq!(workspace_relative("a", "../../escape.ts"), None);
}
#[test]
#[allow(clippy::unwrap_used)]
fn script_targets_select_packages_by_name_pattern_and_directory() {
let fs = MemoryFs::new()
.with_file("/ws/package.json", r#"{"name":"r","workspaces":["packages/*"]}"#)
.with_file("/ws/packages/web/package.json", r#"{"name":"@app/web"}"#)
.with_file("/ws/packages/api/package.json", r#"{"name":"api"}"#);
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(Path::new("/ws"), &config, &fs).unwrap();
let dirs = package_dirs(&workspace);
let selected = |target: &str| {
let target = WorkspaceTarget::new(target);
let mut names: Vec<&str> = workspace
.iter()
.zip(&dirs)
.filter(|((_, package), (_, dir))| target.names(package, dir))
.filter_map(|((_, package), _)| package.name.as_deref())
.collect();
names.sort_unstable();
names
};
assert_eq!(selected("api"), ["api"]);
assert_eq!(selected("...api..."), ["api"]);
assert_eq!(selected("@app/*"), ["@app/web"]);
assert_eq!(selected("./packages/web/"), ["@app/web"]);
assert_eq!(selected("packages/api"), ["api"]);
assert_eq!(selected("!api"), [] as [&str; 0]);
assert_eq!(selected("@app/web/"), [] as [&str; 0]);
}
}