use std::collections::{BTreeMap, BTreeSet};
use skif_core::{HashMap, HashSet};
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::Value;
use skif_core::{DependencyKind, FileSystem, Span};
use skif_js_plugins::scripts::{ScriptContext, is_ignored_binary, parse_script};
use skif_js_syntax::{ConfigValue, ImportKind, extract_config_object, is_declaration_file, place_file_dir};
use skif_specifier::Specifier;
use skif_workspace::{Package, PackageId, PackageManager, Workspace, map_ordered, npmrc_entries};
use crate::ancestors::{external_manifests, is_repository_root, listed_names, read_manifest};
use crate::discover::{is_ignored_in_repository, is_package_manifest, relative};
use crate::fixture::in_fixture_directory;
use crate::graph::{ModuleGraph, ModuleId, Target};
use crate::hoisting::PublicHoisting;
use crate::input::ConfigDependency;
use crate::lockfile;
use crate::resolve::is_installed;
use crate::scripts::{ScriptBinary, script_context};
use crate::setup::{Setup, package_dirs, package_in_dir, placed_dir};
use crate::specifier::{RUNTIME_PACKAGES, bin_link, package_name, typed_package, types_package};
use crate::tsconfig_files::{reference, references_of};
use crate::tsconfig_paths::resolve_extends;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Listed {
Package(PackageId),
External,
}
const ALWAYS_USED: &[&str] = &["typescript", "knip", "skif"];
const COMPILER_DEPENDENCIES: &[(&str, &[&str])] = &[
("marko", &["marko"]),
(
"mdx",
&[
"@mdx-js/esbuild",
"@mdx-js/loader",
"@mdx-js/mdx",
"@mdx-js/node-loader",
"@mdx-js/preact",
"@mdx-js/react",
"@mdx-js/rollup",
"@mdx-js/vue",
"remark-mdx",
],
),
("styl", &["stylus"]),
(
"tsrx",
&[
"@tsrx/core",
"@tsrx/react",
"@tsrx/preact",
"@tsrx/vue",
"@tsrx/solid",
"@tsrx/ripple",
],
),
("stylus", &["stylus"]),
("svelte", &["svelte"]),
];
const UNPAIRED_TYPES: &[&str] = &["node", "bun", "jest"];
const GLOBAL_TYPES: &[&str] = &[
"@types/chrome",
"@types/dom-chromium-ai",
"@types/dom-mediacapture-record",
"@types/dom-speech-recognition",
"@types/dom-webcodecs",
"@types/firefox-webext-browser",
"@types/ga-gtag",
"@types/gapi",
"@types/gapi.auth2",
"@types/gapi.client",
"@types/google.accounts",
"@types/google.analytics",
"@types/google.maps",
"@types/google.picker",
"@types/google-apps-script",
"@types/gtag.js",
"@types/jasmine",
"@types/mocha",
"@types/office-js",
"@types/office-js-preview",
"@types/office-runtime",
"@types/offscreencanvas",
"@types/serviceworker",
"@types/sharedworker",
"@types/trusted-types",
"@types/w3c-web-hid",
"@types/w3c-web-serial",
"@types/w3c-web-usb",
"@types/web",
"@types/web-bluetooth",
"@types/webextension-polyfill",
"@types/webpack-env",
"@types/wicg-file-system-access",
];
const RUSH_JSON: &str = "rush.json";
const RUSH_COMMANDS: &[&str] = &["rush", "rushx", "rush-pnpm"];
fn hoists_member_bins(workspace: &Workspace, rush: bool, bun_config_version: Option<u64>, fs: &dyn FileSystem) -> bool {
if rush {
return false;
}
match workspace.package_manager {
Some(PackageManager::Npm) => !npm_linked(workspace, fs),
Some(PackageManager::Pnpm) => pnpm_hoisted(workspace, fs),
Some(PackageManager::Yarn) => !yarn_berry(workspace, fs),
Some(PackageManager::Bun) => !bun_isolated(fs, &workspace.root, bun_config_version),
_ => true,
}
}
fn yarn_berry(workspace: &Workspace, fs: &dyn FileSystem) -> bool {
if let Some(version) = declared_version(workspace, "yarn") {
return !version.starts_with("1.");
}
fs.is_file(&workspace.root.join(".yarnrc.yml"))
|| fs
.read_to_string(&workspace.root.join("yarn.lock"))
.is_ok_and(|text| lockfile::is_yarn_berry(&text))
}
fn declared_version<'a>(workspace: &'a Workspace, name: &str) -> Option<&'a str> {
workspace
.root_package()
.manifest
.value()
.get("packageManager")
.and_then(Value::as_str)?
.strip_prefix(name)?
.strip_prefix('@')
}
const PNPM_WITHOUT_NPMRC_SETTINGS: u64 = 11;
fn pnpm_hoisted(workspace: &Workspace, fs: &dyn FileSystem) -> bool {
let reads_npmrc = pnpm_reads_npmrc(workspace);
let linker = workspace
.pnpm_workspace
.as_ref()
.and_then(|yaml| yaml.node_linker.clone())
.or_else(|| {
reads_npmrc
.then(|| npmrc_setting(fs, &workspace.root, "node-linker"))
.flatten()
});
linker.as_deref() == Some("hoisted")
}
fn pnpm_reads_npmrc(workspace: &Workspace) -> bool {
declared_version(workspace, "pnpm")
.and_then(major_of)
.is_none_or(|major| major < PNPM_WITHOUT_NPMRC_SETTINGS)
}
fn npm_linked(workspace: &Workspace, fs: &dyn FileSystem) -> bool {
npmrc_setting(fs, &workspace.root, "install-strategy").as_deref() == Some("linked")
}
fn npmrc_setting(fs: &dyn FileSystem, root: &Path, key: &str) -> Option<String> {
let npmrc = fs.read_to_string(&root.join(".npmrc")).ok()?;
npmrc_entries(&npmrc)
.filter(|(entry, _)| *entry == key)
.last()
.map(|(_, value)| value.to_owned())
}
fn bun_isolated(fs: &dyn FileSystem, root: &Path, config_version: Option<u64>) -> bool {
let linker = fs
.read_to_string(&root.join("bunfig.toml"))
.ok()
.and_then(|text| text.parse::<toml::Table>().ok())
.and_then(|table| Some(table.get("install")?.get("linker")?.as_str()?.to_owned()));
match linker.as_deref() {
Some("isolated") => true,
Some("hoisted") => false,
_ => config_version.is_some_and(|version| version >= 1),
}
}
const BINARY_PACKAGES: &[(&str, &str)] = &[
("tsc", "typescript"),
("tsserver", "typescript"),
("ng", "@angular/cli"),
("changeset", "@changesets/cli"),
("commitlint", "@commitlint/cli"),
("graphql-codegen", "@graphql-codegen/cli"),
("biome", "@biomejs/biome"),
("webpack", "webpack-cli"),
("babel", "@babel/cli"),
("nest", "@nestjs/cli"),
("svelte-kit", "@sveltejs/kit"),
("swc", "@swc/cli"),
("docusaurus", "@docusaurus/core"),
("sentry-cli", "@sentry/cli"),
("vue-cli-service", "@vue/cli-service"),
("lhci", "@lhci/cli"),
("redocly", "@redocly/cli"),
("tsgo", "@typescript/native-preview"),
("sb", "storybook"),
("cz", "commitizen"),
("firebase", "firebase-tools"),
("eas", "eas-cli"),
("netlify", "netlify-cli"),
("playwright", "@playwright/test"),
("gql-gen", "@graphql-codegen/cli"),
("nuxi", "nuxt"),
("cdk", "aws-cdk"),
("rspack", "@rspack/cli"),
("rsbuild", "@rsbuild/core"),
("rslib", "@rslib/core"),
("remix", "@remix-run/dev"),
("react-router", "@react-router/dev"),
("tailwindcss", "@tailwindcss/cli"),
("tauri", "@tauri-apps/cli"),
("cap", "@capacitor/cli"),
("capacitor", "@capacitor/cli"),
("ionic", "@ionic/cli"),
("ember", "ember-cli"),
("quasar", "@quasar/cli"),
("mikro-orm", "@mikro-orm/cli"),
("sequelize", "sequelize-cli"),
("sls", "serverless"),
("vsce", "@vscode/vsce"),
("ncu", "npm-check-updates"),
("dotenv", "dotenv-cli"),
("markdownlint", "markdownlint-cli"),
("babel-node", "@babel/node"),
("tspc", "ts-patch"),
("api-extractor", "@microsoft/api-extractor"),
("rush", "@microsoft/rush"),
("manypkg", "@manypkg/cli"),
("graphql-inspector", "@graphql-inspector/cli"),
("graphql-codegen-esm", "@graphql-codegen/cli"),
("buf", "@bufbuild/buf"),
("protoc-gen-es", "@bufbuild/protoc-gen-es"),
("pbjs", "protobufjs-cli"),
("pbts", "protobufjs-cli"),
("wdio", "@wdio/cli"),
("percy", "@percy/cli"),
("electron-forge", "@electron-forge/cli"),
("start-storybook", "@storybook/cli"),
("build-storybook", "@storybook/cli"),
("start-test", "start-server-and-test"),
("server-test", "start-server-and-test"),
("jest-cli", "jest"),
("ts-node-esm", "ts-node"),
("ts-node-cwd", "ts-node"),
("prettier-eslint", "prettier-eslint-cli"),
("openapi-generator-cli", "@openapitools/openapi-generator-cli"),
("pm2-runtime", "pm2"),
];
struct ImportSite<'s> {
owner: PackageId,
specifier: &'s str,
target: Option<&'s Target>,
module: ModuleId,
span: Option<Span>,
type_only: bool,
declaration: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UnlistedImport {
pub package: PackageId,
pub name: String,
pub module: ModuleId,
pub span: Option<Span>,
pub specifier: String,
pub target: Option<Target>,
pub foreign_paths: Option<ForeignMapping>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForeignMapping {
pub config: String,
pub pattern: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnlistedBinary {
pub package: PackageId,
pub name: String,
pub script: String,
pub file: Option<PathBuf>,
pub runs_in: Option<RunsIn>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunsIn {
pub dir: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnlistedConfigDependency {
pub package: PackageId,
pub name: String,
pub file: PathBuf,
pub span: Option<Span>,
pub readers: Vec<PackageId>,
}
pub(crate) struct Installed {
pub(crate) bins: Vec<String>,
pub(crate) peers: Vec<String>,
pub(crate) optional_peers: Vec<String>,
pub(crate) version: Option<String>,
}
pub(crate) fn bins_of(manifest: &Value, name: &str) -> Vec<String> {
match manifest.get("bin") {
Some(Value::String(_)) => vec![unscoped(name).to_owned()],
Some(Value::Object(map)) => map.keys().cloned().collect(),
_ => Vec::new(),
}
}
pub(crate) fn peers_of(manifest: &Value) -> Vec<String> {
manifest
.get("peerDependencies")
.and_then(Value::as_object)
.map_or_default(|map| map.keys().cloned().collect())
}
pub(crate) fn optional_peers_of(manifest: &Value) -> Vec<String> {
manifest
.get("peerDependenciesMeta")
.and_then(Value::as_object)
.map_or_default(|meta| {
meta.iter()
.filter(|(_, entry)| entry.get("optional").and_then(Value::as_bool) == Some(true))
.map(|(name, _)| name.clone())
.collect()
})
}
const GENERIC_WORDS: &[&str] = &[
"cli", "core", "dev", "kit", "tool", "tools", "js", "node", "bin", "run", "start",
];
fn words(name: &str) -> impl Iterator<Item = &str> {
name.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| word.len() > 1 && !GENERIC_WORDS.contains(word))
}
fn resembles(dependency: &str, bin: &str) -> bool {
let short = unscoped(dependency);
(bin.len() > 2 && short.starts_with(bin))
|| (short.len() > 2 && bin.starts_with(short))
|| words(dependency).any(|word| words(bin).any(|other| other == word))
}
fn unscoped(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
fn listed_bins(
fs: &dyn FileSystem,
dir: &Path,
name: &str,
cache: &mut HashMap<PathBuf, Option<Arc<Installed>>>,
) -> Vec<String> {
let mut bins = vec![unscoped(name).to_owned()];
bins.extend(
BINARY_PACKAGES
.iter()
.filter(|(_, package)| *package == name)
.map(|(bin, _)| (*bin).to_owned()),
);
if let Some(installed) = read_installed(fs, dir, dir, name, cache) {
bins.extend(installed.bins.iter().cloned());
}
bins
}
fn project_between(fs: &dyn FileSystem, root: &Path, package: &str, target: &str) -> Option<(PathBuf, Value)> {
let package = root.join(package);
let target = root.join(target);
let between: Vec<&Path> = target.ancestors().take_while(|dir| *dir != package).collect();
if !target.starts_with(&package) || between.iter().any(|dir| dir.ends_with("node_modules")) {
return None;
}
let dir = between
.into_iter()
.find(|dir| fs.is_file(&dir.join("package.json")) && is_package_manifest(fs, dir))?;
Some((dir.to_path_buf(), read_manifest(fs, dir).unwrap_or(Value::Null)))
}
#[derive(Clone, Copy)]
enum Origin<'a> {
Script(&'a str),
File(&'a Path),
}
#[derive(Clone, Copy)]
struct CommandBinary<'a> {
name: &'a str,
dir: Option<&'a str>,
filtered: bool,
judged: bool,
origin: Origin<'a>,
}
struct Host {
package: PackageId,
project: Option<(PathBuf, Value)>,
runs_in: RunsIn,
}
impl Host {
fn has_script(&self, workspace: &Workspace, name: &str) -> bool {
let manifest = match &self.project {
Some((_, manifest)) => Some(manifest),
None => workspace.package(self.package).map(|package| package.manifest.value()),
};
manifest
.and_then(|manifest| manifest.get("scripts"))
.and_then(|scripts| scripts.get(name))
.is_some()
}
}
fn read_installed(
fs: &dyn FileSystem,
start: &Path,
root: &Path,
name: &str,
cache: &mut HashMap<PathBuf, Option<Arc<Installed>>>,
) -> Option<Arc<Installed>> {
find_installed(start, root, |dir| {
let package = dir.join("node_modules").join(name);
let manifest = package.join("package.json");
cache
.entry(manifest.clone())
.or_insert_with(|| read_installed_at(fs, &package, &manifest, name))
.clone()
})
}
fn find_installed(
start: &Path,
root: &Path,
mut lookup: impl FnMut(&Path) -> Option<Arc<Installed>>,
) -> Option<Arc<Installed>> {
let mut dir = start;
loop {
if let Some(found) = lookup(dir) {
return Some(found);
}
if dir == root {
return None;
}
dir = dir.parent()?;
if !dir.starts_with(root) {
return None;
}
}
}
fn read_installed_at(fs: &dyn FileSystem, package: &Path, manifest: &Path, name: &str) -> Option<Arc<Installed>> {
fs.read_to_string(manifest)
.ok()
.and_then(|text| parse_installed(fs, package, &text, name))
}
#[derive(Default)]
struct SharedInstalled {
manifests: Mutex<HashMap<PathBuf, Option<Arc<Installed>>>>,
module_dirs: Mutex<HashMap<PathBuf, bool>>,
}
impl SharedInstalled {
fn read(&self, fs: &dyn FileSystem, start: &Path, root: &Path, name: &str) -> Option<Arc<Installed>> {
find_installed(start, root, |dir| {
if !self.has_node_modules(fs, dir) {
return None;
}
let package = dir.join("node_modules").join(name);
let manifest = package.join("package.json");
if let Some(known) = self
.manifests
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&manifest)
{
return known.clone();
}
let read = read_installed_at(fs, &package, &manifest, name);
self.manifests
.lock()
.unwrap_or_else(PoisonError::into_inner)
.entry(manifest)
.or_insert(read)
.clone()
})
}
fn has_node_modules(&self, fs: &dyn FileSystem, dir: &Path) -> bool {
if let Some(&known) = self.module_dirs.lock().unwrap_or_else(PoisonError::into_inner).get(dir) {
return known;
}
let found = fs.is_dir(&dir.join("node_modules"));
*self
.module_dirs
.lock()
.unwrap_or_else(PoisonError::into_inner)
.entry(dir.to_path_buf())
.or_insert(found)
}
}
fn version_of(manifest: &Value) -> Option<String> {
manifest.get("version").and_then(Value::as_str).map(str::to_owned)
}
fn parse_installed(fs: &dyn FileSystem, dir: &Path, text: &str, name: &str) -> Option<Arc<Installed>> {
let value: Value = serde_json::from_str(text).ok()?;
let bins = if value.get("bin").is_some() {
bins_of(&value, name)
} else {
directory_bins(fs, dir, &value)
};
Some(Arc::new(Installed {
bins,
peers: peers_of(&value),
optional_peers: optional_peers_of(&value),
version: version_of(&value),
}))
}
fn directory_bins(fs: &dyn FileSystem, dir: &Path, manifest: &Value) -> Vec<String> {
let Some(bin_dir) = manifest
.get("directories")
.and_then(|directories| directories.get("bin"))
.and_then(Value::as_str)
else {
return Vec::new();
};
let mut pending = vec![dir.join(clamped(Path::new(bin_dir)))];
let mut names = BTreeSet::new();
while let Some(current) = pending.pop() {
let Ok(entries) = fs.read_dir(¤t) else {
continue;
};
for entry in entries {
let Some(name) = entry.path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
if entry.is_dir && !entry.is_symlink {
pending.push(entry.path);
} else if entry.is_file {
names.insert(name.to_owned());
}
}
}
names.into_iter().collect()
}
fn clamped(path: &Path) -> PathBuf {
let mut parts: Vec<&OsStr> = Vec::new();
for component in path.components() {
match component {
Component::Normal(part) => parts.push(part),
Component::ParentDir => {
parts.pop();
}
Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
}
}
parts.into_iter().collect()
}
pub(crate) fn parse_tsconfig(fs: &dyn FileSystem, path: &Path) -> Option<ConfigValue> {
let text = fs.read_to_string(path).ok()?;
extract_config_object(&format!("module.exports = {text}"), Path::new("tsconfig.js"))
}
struct TsconfigMemo<'a> {
fs: &'a dyn FileSystem,
parsed: Mutex<HashMap<PathBuf, Option<Arc<ConfigValue>>>>,
}
impl<'a> TsconfigMemo<'a> {
fn new(fs: &'a dyn FileSystem) -> Self {
Self {
fs,
parsed: Mutex::new(HashMap::default()),
}
}
fn get(&self, path: &Path) -> Option<Arc<ConfigValue>> {
if let Some(known) = self.parsed.lock().unwrap_or_else(PoisonError::into_inner).get(path) {
return known.clone();
}
let config = parse_tsconfig(self.fs, path).map(Arc::new);
self.parsed
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(path.to_path_buf(), config.clone());
config
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum AmbientTypes {
Narrowed,
Default,
Every,
}
fn ambient_types(memo: &TsconfigMemo<'_>, members: &[(String, PathBuf)], path: &Path) -> AmbientTypes {
if memo.get(path).is_none() {
return AmbientTypes::Narrowed;
}
let keeps_default_root = |options: &ConfigValue| match options.get("typeRoots") {
Some(ConfigValue::Array(roots)) => Some(roots.iter().filter_map(ConfigValue::as_str).any(|root| {
let root = root.trim_end_matches('/');
root.ends_with("node_modules/@types") || root == "@types"
})),
_ => None,
};
if inherited_option(memo, members, path, 0, &keeps_default_root) == Some(false) {
return AmbientTypes::Narrowed;
}
let lists_every = |options: &ConfigValue| options.get("types").map(lists_every_type);
match inherited_option(memo, members, path, 0, &lists_every) {
Some(true) => AmbientTypes::Every,
Some(false) => AmbientTypes::Narrowed,
None => AmbientTypes::Default,
}
}
fn project_ambient_types(memo: &TsconfigMemo<'_>, members: &[(String, PathBuf)], path: &Path) -> AmbientTypes {
let own = ambient_types(memo, members, path);
let Some(config) = memo.get(path) else {
return own;
};
let dir = path.parent().unwrap_or(path);
let referenced = references_of(&config)
.into_iter()
.filter_map(|target| reference(memo.fs, dir, target))
.filter(|target| target.starts_with(dir) && target != path)
.map(|target| ambient_types(memo, members, &target));
referenced.fold(own, Ord::max)
}
fn typescript_major(
fs: &dyn FileSystem,
workspace: &Workspace,
package: &Package,
installed: &mut HashMap<PathBuf, Option<Arc<Installed>>>,
root_declared: Option<u64>,
) -> Option<u64> {
let dir = workspace.root.join(&package.dir);
read_installed(fs, &dir, &workspace.root, "typescript", installed)
.and_then(|typescript| typescript.version.as_deref().and_then(major_of))
.or_else(|| declared_typescript_major(workspace, package))
.or(root_declared)
}
fn declared_typescript_major(workspace: &Workspace, package: &Package) -> Option<u64> {
let typescript = move |peer: bool| {
package.dependencies.iter().filter(move |entry| {
&*entry.name == "typescript" && (entry.kind == DependencyKind::PeerDependencies) == peer
})
};
let peer = typescript(false).next().is_none();
typescript(peer)
.filter_map(
|entry| match workspace.catalogs.resolve(&entry.name, &entry.specifier) {
Some(version) => lowest_major(&Specifier::new(version)),
None => lowest_major(&entry.parsed),
},
)
.min()
}
fn lowest_major(specifier: &Specifier) -> Option<u64> {
let range = match specifier {
Specifier::Latest(_) | Specifier::None => return None,
Specifier::Unsupported(raw) => Specifier::new_node_range(raw)?,
_ => specifier.get_node_range()?,
};
range.min_version().map(|version| version.major())
}
fn major_of(version: &str) -> Option<u64> {
version.trim().split(['.', '-', '+']).next()?.parse().ok()
}
fn lists_every_type(types: &ConfigValue) -> bool {
match types {
ConfigValue::Array(items) => items
.iter()
.any(|item| item.as_str().is_some_and(|name| name.trim() == "*")),
_ => false,
}
}
fn extends_of(config: &ConfigValue) -> Vec<&str> {
match config.get("extends") {
Some(ConfigValue::String(one)) => vec![one.as_str()],
Some(ConfigValue::Array(many)) => many.iter().filter_map(ConfigValue::as_str).collect(),
_ => Vec::new(),
}
}
fn jsx_import_source(memo: &TsconfigMemo<'_>, members: &[(String, PathBuf)], path: &Path) -> Option<String> {
inherited_option(memo, members, path, 0, &|options: &ConfigValue| {
options
.get("jsxImportSource")
.and_then(ConfigValue::as_str)
.map(str::to_owned)
})
}
fn inherited_option<T>(
memo: &TsconfigMemo<'_>,
members: &[(String, PathBuf)],
path: &Path,
depth: usize,
read: &impl Fn(&ConfigValue) -> Option<T>,
) -> Option<T> {
const MAX_DEPTH: usize = 8;
let config = memo.get(path)?;
if let Some(value) = config.get("compilerOptions").and_then(read) {
return Some(value);
}
if depth >= MAX_DEPTH {
return None;
}
let dir = path.parent().unwrap_or(path);
extends_of(&config)
.iter()
.rev()
.filter_map(|parent| resolve_extends(memo.fs, members, dir, parent))
.find_map(|target| inherited_option(memo, members, &target, depth + 1, read))
}
pub(crate) fn member_dirs(workspace: &Workspace) -> Vec<(String, PathBuf)> {
workspace
.all_packages()
.filter_map(|package| Some((package.name.clone()?, workspace.root.join(&package.dir))))
.collect()
}
fn config_owner(workspace: &Workspace, setup: &Setup, file: &Path) -> Option<PackageId> {
workspace
.iter()
.filter_map(|(id, _)| setup.package(id).map(|package_setup| (id, &package_setup.dir)))
.filter(|(_, dir)| file.starts_with(dir))
.max_by_key(|(_, dir)| dir.components().count())
.map(|(id, _)| id)
}
fn judged_config(
workspace: &Workspace,
setup: &Setup,
fs: &dyn FileSystem,
file: &Path,
) -> Option<(Option<PackageId>, String)> {
let rel = relative(file, &workspace.root)?;
let in_node_modules = rel.split('/').any(|part| part == "node_modules");
let in_fixture = !setup.include_fixtures && in_fixture_directory(Path::new(&rel));
if in_node_modules || in_fixture || setup.global_ignore.matches_any(&rel) {
return None;
}
if setup.gitignore && is_ignored_in_repository(fs, &workspace.root, file) {
return None;
}
let owner = config_owner(workspace, setup, file);
let ignored = owner.and_then(|owner| setup.package(owner)).is_some_and(|owner_setup| {
relative(file, &owner_setup.dir).is_some_and(|rel| owner_setup.ignore.matches_any(&rel))
});
(!ignored).then_some((owner, rel))
}
fn package_ancestors(workspace: &Workspace, isolate: bool) -> Vec<Vec<PackageId>> {
if isolate {
return vec![Vec::new(); workspace.package_ids().count()];
}
let root = workspace.iter().find(|(_, package)| package.is_root).map(|(id, _)| id);
let mut by_dir: HashMap<&Path, Vec<PackageId>> = HashMap::default();
for (id, package) in workspace.iter().filter(|(_, package)| !package.is_root) {
by_dir.entry(package.dir.as_path()).or_default().push(id);
}
let mut all = vec![Vec::new(); workspace.package_ids().count()];
for (id, package) in workspace.iter() {
all[id.index()] = package
.dir
.ancestors()
.skip(1)
.flat_map(|dir| by_dir.get(dir).into_iter().flatten().copied())
.chain(root.filter(|root| *root != id))
.collect();
}
all
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Use {
Import(ModuleId),
Mock(ModuleId),
Compiler(ModuleId),
JsxRuntime(ModuleId),
Command(PathBuf),
Binary(String),
#[default]
Tooling,
Config(PathBuf),
AmbientTypes,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Credit<'a> {
AlwaysUsed,
Uses(&'a BTreeSet<Use>),
UnpairedTypes,
TypesOf(String),
PeerOf(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Mode {
#[default]
Default,
Isolated,
Strict,
}
impl Mode {
fn of(strict: bool, isolated: bool) -> Self {
match (strict, isolated) {
(true, _) => Self::Strict,
(false, true) => Self::Isolated,
(false, false) => Self::Default,
}
}
fn strict(self) -> bool {
self == Self::Strict
}
fn isolated(self) -> bool {
self != Self::Default
}
}
#[derive(Debug, Clone, Default)]
pub struct DependencyUsage {
referenced: Vec<HashMap<String, BTreeSet<Use>>>,
via: Use,
listed: Vec<HashSet<Arc<str>>>,
production_listed: Vec<HashSet<Arc<str>>>,
mode: Mode,
ancestors: Vec<Vec<PackageId>>,
known_bins: Vec<HashSet<String>>,
other_jsx_runtime: Vec<bool>,
external_listed: HashMap<String, PathBuf>,
external_used: BTreeSet<String>,
outside_used: BTreeSet<(PathBuf, String)>,
hosts: Vec<HashMap<String, Vec<String>>>,
required_peers: Vec<HashSet<String>>,
bins: Vec<HashMap<String, Vec<String>>>,
member_bins: BTreeMap<String, Vec<(PackageId, String)>>,
member_bins_hoisted: bool,
rush: bool,
public_hoisting: Option<PublicHoisting>,
dirs: Vec<(PackageId, String)>,
members: HashSet<String>,
ancestor_bins: HashMap<String, Vec<String>>,
pub unlisted: Vec<UnlistedImport>,
pub unlisted_binaries: Vec<UnlistedBinary>,
pub unlisted_config: Vec<UnlistedConfigDependency>,
unlisted_config_index: UnlistedConfigIndex,
}
#[derive(Debug, Clone, Default)]
struct UnlistedConfigIndex {
positions: HashMap<(PackageId, String, PathBuf), usize>,
readers: HashSet<(usize, PackageId)>,
}
impl UnlistedConfigIndex {
fn record(
&mut self,
list: &mut Vec<UnlistedConfigDependency>,
package: PackageId,
name: &str,
file: PathBuf,
span: Option<Span>,
reader: Option<PackageId>,
) {
let position = *self
.positions
.entry((package, name.to_owned(), file))
.or_insert_with_key(|(package, name, file)| {
list.push(UnlistedConfigDependency {
package: *package,
name: name.clone(),
file: file.clone(),
span: None,
readers: Vec::new(),
});
list.len() - 1
});
let entry = &mut list[position];
entry.span = entry.span.or(span);
if let Some(reader) = reader
&& self.readers.insert((position, reader))
{
entry.readers.push(reader);
}
}
}
impl DependencyUsage {
pub(crate) fn compute(
workspace: &Workspace,
setup: &Setup,
graph: &ModuleGraph,
fs: &dyn FileSystem,
sequential: bool,
) -> Self {
let count = workspace.package_ids().count();
let mut usage = Self {
referenced: vec![HashMap::default(); count],
listed: vec![HashSet::default(); count],
production_listed: vec![HashSet::default(); count],
mode: Mode::of(setup.strict, setup.isolate_workspaces),
known_bins: vec![HashSet::default(); count],
other_jsx_runtime: vec![false; count],
ancestors: package_ancestors(workspace, setup.isolate_workspaces),
hosts: vec![HashMap::default(); count],
required_peers: vec![HashSet::default(); count],
bins: vec![HashMap::default(); count],
members: workspace.all_packages().filter_map(|p| p.name.clone()).collect(),
rush: fs.is_file(&workspace.root.join(RUSH_JSON)),
dirs: package_dirs(workspace),
..Self::default()
};
let locked = lockfile::read(fs, &workspace.root);
usage.member_bins_hoisted = hoists_member_bins(workspace, usage.rush, locked.bun_config_version, fs);
if setup.use_public_hoisting && !setup.isolate_workspaces {
let pnpm = (matches!(workspace.package_manager, Some(PackageManager::Pnpm)) && !usage.rush)
.then(|| pnpm_reads_npmrc(workspace));
usage.public_hoisting = Some(PublicHoisting::read(workspace, pnpm, setup.git_root.as_deref(), fs));
}
usage.read_manifests(workspace, &locked.packages, fs, sequential);
let tsconfigs = TsconfigMemo::new(fs);
usage.collect_ambient_types(workspace, setup, &tsconfigs);
usage.collect_jsx_runtimes(workspace, setup, &tsconfigs);
usage.collect_ancestor_bins(workspace, fs);
usage.collect_member_bins(workspace);
usage.collect_imports(workspace, setup, graph, fs);
usage.collect_outside_imports(workspace, graph, fs);
usage.collect_scripts(workspace, setup, fs);
usage.collect_config_dependencies(workspace, setup, fs);
usage
}
fn read_manifests(
&mut self,
workspace: &Workspace,
locked: &HashMap<String, Arc<Installed>>,
fs: &dyn FileSystem,
sequential: bool,
) {
let linked: HashMap<String, Arc<Installed>> = workspace
.all_packages()
.filter_map(|package| {
let name = package.name.clone()?;
let manifest = package.manifest.value();
let installed = Installed {
bins: bins_of(manifest, &name),
peers: peers_of(manifest),
optional_peers: optional_peers_of(manifest),
version: version_of(manifest),
};
Some((name, Arc::new(installed)))
})
.collect();
let members: Vec<(PackageId, &Package)> = workspace.iter().collect();
for &(id, package) in &members {
for entry in &package.dependencies {
self.listed[id.index()].insert(Arc::clone(&entry.name));
if matches!(
entry.kind,
DependencyKind::Dependencies | DependencyKind::PeerDependencies
) {
self.production_listed[id.index()].insert(Arc::clone(&entry.name));
}
}
}
let listed = &self.listed;
let shared = SharedInstalled::default();
let installed: Vec<Vec<(String, Arc<Installed>)>> = map_ordered(&members, sequential, |&(id, package)| {
let dir = workspace.root.join(&package.dir);
listed[id.index()]
.iter()
.filter_map(|name| {
let found = shared
.read(fs, &dir, &workspace.root, name)
.or_else(|| linked.get(&**name).cloned())
.or_else(|| locked.get(&**name).cloned())?;
Some((name.to_string(), found))
})
.collect()
});
for (&(id, _), found) in members.iter().zip(installed) {
for (name, installed) in found {
self.known_bins[id.index()].insert(name.clone());
for bin in &installed.bins {
self.bins[id.index()].entry(bin.clone()).or_default().push(name.clone());
}
for peer in &installed.peers {
self.hosts[id.index()]
.entry(peer.clone())
.or_default()
.push(name.clone());
if !installed.optional_peers.contains(peer) {
self.required_peers[id.index()].insert(peer.clone());
}
}
}
}
}
fn collect_ambient_types(&mut self, workspace: &Workspace, setup: &Setup, tsconfigs: &TsconfigMemo<'_>) {
const TYPES_DEFAULT_EMPTY_SINCE: u64 = 6;
let members = member_dirs(workspace);
let mut installed = HashMap::default();
let root_declared = workspace
.package(PackageId::ROOT)
.and_then(|root| declared_typescript_major(workspace, root));
for (id, package) in workspace.iter() {
let tsconfig = setup.tsconfig_of(workspace, package);
let includes_all = match project_ambient_types(tsconfigs, &members, &tsconfig) {
AmbientTypes::Narrowed => false,
AmbientTypes::Every => true,
AmbientTypes::Default => {
typescript_major(tsconfigs.fs, workspace, package, &mut installed, root_declared)
.is_none_or(|major| major < TYPES_DEFAULT_EMPTY_SINCE)
}
};
if !includes_all {
continue;
}
for scope in [id, PackageId::ROOT] {
let types: Vec<String> = self.listed[scope.index()]
.iter()
.filter(|name| GLOBAL_TYPES.contains(&&***name))
.map(ToString::to_string)
.collect();
for name in types {
self.with_via(Use::AmbientTypes, |usage| usage.reference_in(scope, &name));
}
}
}
}
fn collect_jsx_runtimes(&mut self, workspace: &Workspace, setup: &Setup, tsconfigs: &TsconfigMemo<'_>) {
let members = member_dirs(workspace);
for (id, package) in workspace.iter() {
let config = setup.tsconfig_of(workspace, package);
self.other_jsx_runtime[id.index()] = jsx_import_source(tsconfigs, &members, &config)
.is_some_and(|source| package_name(&source).is_some_and(|name| name != "react"));
}
}
fn collect_ancestor_bins(&mut self, workspace: &Workspace, fs: &dyn FileSystem) {
let mut cache = HashMap::default();
for (dir, manifest) in external_manifests(fs, &workspace.root) {
for name in listed_names(&manifest) {
self.external_listed
.entry(name.to_owned())
.or_insert_with(|| dir.clone());
for bin in listed_bins(fs, &dir, name, &mut cache) {
let providers = self.ancestor_bins.entry(bin).or_default();
if !providers.iter().any(|provider| provider == name) {
providers.push(name.to_owned());
}
}
}
}
}
fn collect_outside_imports(&mut self, workspace: &Workspace, graph: &ModuleGraph, fs: &dyn FileSystem) {
let mut listed_in: HashMap<PathBuf, HashSet<String>> = HashMap::default();
let outside = graph
.modules()
.iter()
.filter(|module| !module.abs.starts_with(&workspace.root) && !is_installed(&module.abs));
for module in outside {
for name in module.imports.iter().filter_map(|import| import.package.as_deref()) {
let mut dir = module.abs.parent();
while let Some(current) = dir {
let names = listed_in.entry(current.to_path_buf()).or_insert_with(|| {
read_manifest(fs, current)
.map_or_default(|manifest| listed_names(&manifest).map(str::to_owned).collect())
});
if names.contains(name) {
self.outside_used.insert((current.to_path_buf(), name.to_owned()));
break;
}
if is_repository_root(fs, current) {
break;
}
dir = current.parent();
}
}
}
}
fn collect_member_bins(&mut self, workspace: &Workspace) {
for (id, package) in workspace.iter() {
let Some(name) = package.name.as_deref() else { continue };
for bin in bins_of(package.manifest.value(), name) {
self.member_bins.entry(bin).or_default().push((id, name.to_owned()));
}
}
}
fn find_listed(&self, id: PackageId, name: &str) -> Option<Listed> {
std::iter::once(id)
.chain(self.ancestors[id.index()].iter().copied())
.find(|scope| self.listed[scope.index()].contains(name))
.map(Listed::Package)
.or_else(|| self.external_listed.contains_key(name).then_some(Listed::External))
}
pub(crate) fn known_package(&self, name: &str) -> bool {
self.members.contains(name)
|| self.external_listed.contains_key(name)
|| self.listed.iter().any(|listed| listed.contains(name))
}
fn reference(&mut self, listed: Listed, name: &str) {
match listed {
Listed::Package(id) => self.reference_in(id, name),
Listed::External => {
if !self.external_used.contains(name) {
self.external_used.insert(name.to_owned());
}
}
}
}
fn reference_listed(&mut self, id: PackageId, specifier: &str) {
if let Some(name) = package_name(specifier)
&& let Some(found) = self.find_listed(id, &name)
{
self.reference(found, &name);
}
}
fn reference_in(&mut self, id: PackageId, name: &str) {
let referenced = &mut self.referenced[id.index()];
if !referenced.contains_key(name) {
referenced.insert(name.to_owned(), BTreeSet::new());
}
if let Some(uses) = referenced.get_mut(name)
&& !uses.contains(&self.via)
{
uses.insert(self.via.clone());
}
}
fn with_via<R>(&mut self, via: Use, collect: impl FnOnce(&mut Self) -> R) -> R {
let outer = std::mem::replace(&mut self.via, via);
let result = collect(self);
self.via = outer;
result
}
fn collect_imports(&mut self, workspace: &Workspace, setup: &Setup, graph: &ModuleGraph, fs: &dyn FileSystem) {
for module in graph.modules() {
if module.foreign || setup.package(module.owner).is_none() {
continue;
}
let declaration = module.abs.to_str().is_some_and(is_declaration_file);
self.via = Use::Import(module.id);
for resolved in &module.imports {
if let Some(name) = &resolved.package {
let kind = module.info.imports.get(resolved.import).map(|import| import.kind);
if kind == Some(ImportKind::Mock) {
if let Some(found) = self.find_listed(module.owner, name) {
self.with_via(Use::Mock(module.id), |usage| usage.reference(found, name));
}
continue;
}
if self.mode.strict() && resolved.type_only {
continue;
}
let type_only = (resolved.type_only && kind != Some(ImportKind::ExportFrom))
|| (self.mode.strict() && declaration);
let import = module.info.imports.get(resolved.import);
let site = ImportSite {
owner: module.owner,
specifier: import.map_or(name.as_str(), |import| import.specifier.as_str()),
target: Some(&resolved.target),
module: module.id,
span: import.map(|import| import.span),
type_only,
declaration,
};
self.handle_import(workspace, name, &site);
} else if let Some(bin) = module
.info
.imports
.get(resolved.import)
.and_then(|import| bin_link(&import.specifier))
{
if !self.provide_binary_or_ancestor(module.owner, bin)
&& !module.ignored
&& !setup.ignores_binary(module.owner, bin)
{
self.push_unlisted_binary(module.owner, bin, &module.path);
}
} else if let Some(import) = module.info.imports.get(resolved.import) {
self.reference_listed(module.owner, &import.specifier);
}
}
let extension = module
.abs
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
if let Some((_, compilers)) = COMPILER_DEPENDENCIES
.iter()
.find(|(wanted, _)| extension.as_deref() == Some(*wanted))
{
for compiler in *compilers {
if let Some(found) = self.find_listed(module.owner, compiler) {
self.with_via(Use::Compiler(module.id), |usage| usage.reference(found, compiler));
}
}
}
if !module.info.commands.is_empty() && !module.ignored && !in_fixture_directory(&module.path) {
let context = workspace
.package(module.owner)
.map_or_else(ScriptContext::new, |package| script_context(package.manifest.value()));
self.with_via(Use::Command(module.path.clone()), |usage| {
usage.collect_commands(
(workspace, setup, fs),
module.owner,
&module.path,
&module.info.commands,
&context,
);
});
}
for name in module.info.ambient_modules.iter().chain(&module.info.augmented_modules) {
self.reference_listed(module.owner, name);
}
if let Some(pragma) = module.info.jsx_import_source.as_deref()
&& let Some(source) = package_name(pragma)
{
let site = ImportSite {
owner: module.owner,
specifier: pragma,
target: None,
module: module.id,
span: module.info.jsx_import_source_span,
type_only: false,
declaration,
};
self.handle_import(workspace, &source, &site);
} else if module.info.has_jsx
&& !self.other_jsx_runtime[module.owner.index()]
&& let Some(found) = self.find_listed(module.owner, "react")
{
self.with_via(Use::JsxRuntime(module.id), |usage| usage.reference(found, "react"));
}
}
}
fn find_listed_for(&self, site: &ImportSite<'_>, name: &str) -> Option<Listed> {
if !self.mode.strict() || site.declaration {
return self.find_listed(site.owner, name);
}
if self.production_listed[site.owner.index()].contains(name) {
Some(Listed::Package(site.owner))
} else {
self.external_listed.contains_key(name).then_some(Listed::External)
}
}
fn handle_import(&mut self, workspace: &Workspace, name: &str, site: &ImportSite<'_>) {
let find = |usage: &Self, name: &str| usage.find_listed_for(site, name);
if self.is_satisfied(workspace, site.owner, name, site.type_only, find) {
return;
}
self.unlisted.push(UnlistedImport {
package: site.owner,
name: name.to_owned(),
module: site.module,
span: site.span,
specifier: site.specifier.to_owned(),
target: site.target.cloned(),
foreign_paths: None,
});
}
fn is_satisfied(
&mut self,
workspace: &Workspace,
owner: PackageId,
name: &str,
type_only: bool,
find: impl Fn(&Self, &str) -> Option<Listed>,
) -> bool {
if RUNTIME_PACKAGES.contains(&name) {
return true;
}
let Some(package) = workspace.package(owner) else {
return true;
};
if package.name.as_deref() == Some(name) {
return true;
}
let types = types_package(name).and_then(|types| find(self, &types).map(|found| (found, types)));
if let Some((found, types)) = &types {
self.reference(*found, types);
}
if let Some(found) = find(self, name) {
self.reference(found, name);
return true;
}
if type_only && types.is_some() {
return true;
}
self.is_provided(workspace, owner, name) || self.use_unlisted_member(package, owner, name)
}
fn is_provided(&self, workspace: &Workspace, owner: PackageId, name: &str) -> bool {
if let Some(hoisting) = &self.public_hoisting
&& let Some(package) = workspace.package(owner)
&& hoisting.hoists(&workspace.root.join(&package.dir), name)
{
return true;
}
let scopes = std::iter::once(owner).chain(self.ancestors[owner.index()].iter().copied());
if !self.mode.strict()
&& scopes
.into_iter()
.any(|scope| self.required_peers[scope.index()].contains(name))
{
return true;
}
std::iter::once(owner)
.chain(self.ancestors[owner.index()].iter().copied())
.filter_map(|scope| workspace.package(scope))
.any(|scope| {
scope
.manifest
.value()
.get("engines")
.and_then(|engines| engines.get(name))
.is_some()
})
}
fn use_unlisted_member(&mut self, package: &Package, owner: PackageId, name: &str) -> bool {
let member = !self.mode.isolated() && package.private && self.members.contains(name);
if member {
self.reference_in(owner, name);
}
member
}
fn collect_config_dependencies(&mut self, workspace: &Workspace, setup: &Setup, fs: &dyn FileSystem) {
if setup.production {
return;
}
let mut judged: BTreeMap<&Path, Option<(Option<PackageId>, String)>> = BTreeMap::new();
for (reader, _) in workspace.iter() {
let Some(package_setup) = setup.package(reader) else {
continue;
};
for dependency in &package_setup.config_dependencies {
let verdict = judged
.entry(dependency.file.as_path())
.or_insert_with(|| judged_config(workspace, setup, fs, &dependency.file));
let Some((owner, file)) = verdict.clone() else {
continue;
};
let owner = if dependency.reader_lists {
reader
} else {
owner.unwrap_or(reader)
};
let may_list = dependency.reader_may_list.then_some(reader);
self.with_via(Use::Config(PathBuf::from(&file)), |usage| {
usage.config_dependency(workspace, (owner, may_list), dependency, &file);
});
}
}
}
fn config_dependency(
&mut self,
workspace: &Workspace,
(owner, reader): (PackageId, Option<PackageId>),
dependency: &ConfigDependency,
file: &str,
) {
let name = dependency.name.as_str();
let judge = |usage: &mut Self, scope: PackageId| {
usage.is_satisfied(workspace, scope, name, dependency.type_only, |usage, name| {
usage.find_listed(scope, name)
})
};
let by_owner = judge(self, owner);
let by_reader = reader.is_some_and(|reader| reader != owner && judge(self, reader));
if by_owner || by_reader {
return;
}
self.unlisted_config_index.record(
&mut self.unlisted_config,
owner,
name,
PathBuf::from(file),
dependency.span,
reader,
);
}
fn collect_scripts(&mut self, workspace: &Workspace, setup: &Setup, fs: &dyn FileSystem) {
self.via = Use::Tooling;
for (id, _) in workspace.iter() {
let Some(package_setup) = setup.package(id) else {
continue;
};
for name in &package_setup.referenced_dependencies {
if let Some(found) = self.find_listed(id, name) {
self.reference(found, name);
}
}
for bin in &package_setup.referenced_binaries {
self.provide_binary_or_ancestor(id, bin);
}
for binary in &package_setup.scripts.ephemeral_binaries {
self.provide_binary(id, &binary.name);
}
for manager in &package_setup.scripts.package_managers {
if let Some(found) = self.find_listed(id, manager) {
self.reference(found, manager);
}
}
let global_binaries: &[ScriptBinary] = if setup.ignore_global_binaries {
&[]
} else {
&package_setup.scripts.global_binaries
};
for binary in package_setup.scripts.binaries.iter().chain(global_binaries) {
if !self.provide_binary_or_ancestor(id, &binary.name) && !setup.ignores_binary(id, &binary.name) {
self.unlisted_binaries.push(UnlistedBinary {
package: id,
name: binary.name.clone(),
script: binary.script.clone(),
file: None,
runs_in: None,
});
}
}
for relocated in &package_setup.scripts.relocated_binaries {
let binary = CommandBinary {
name: &relocated.binary.name,
dir: Some(relocated.dir.as_str()),
filtered: relocated.filtered,
judged: true,
origin: Origin::Script(&relocated.binary.script),
};
self.command_binary((workspace, setup, fs), id, binary);
}
for run in &package_setup.run_binaries {
let file = relative(&run.file, &workspace.root).map_or_else(|| run.file.clone(), PathBuf::from);
let binary = CommandBinary {
name: &run.name,
dir: run.dir.as_deref(),
filtered: run.filtered,
judged: run.judged,
origin: Origin::File(&file),
};
self.command_binary((workspace, setup, fs), id, binary);
}
}
}
fn collect_commands(
&mut self,
cx: (&Workspace, &Setup, &dyn FileSystem),
id: PackageId,
file: &Path,
commands: &[String],
context: &ScriptContext,
) {
let path = self.path_in_package(id, file);
for command in commands {
let command = path
.as_deref()
.map_or_else(|| command.clone(), |path| place_file_dir(command, path));
let info = parse_script(&command, context);
for dependency in &info.dependencies {
let owner = dependency
.dir
.as_deref()
.map_or(id, |dir| package_in_dir(&self.dirs, id, dir));
if let Some(found) = self.find_listed(owner, &dependency.name) {
self.reference(found, &dependency.name);
}
}
let global_binaries = if cx.1.ignore_global_binaries {
Vec::new()
} else {
info.global_binaries
};
let binaries = info
.binaries
.into_iter()
.filter(|binary| !is_ignored_binary(&binary.name));
for binary in binaries.chain(global_binaries) {
let run = CommandBinary {
name: &binary.name,
dir: binary.relocation_dir(),
filtered: binary.filtered,
judged: !binary.via.is_ephemeral(),
origin: Origin::File(file),
};
self.command_binary(cx, id, run);
}
}
}
fn path_in_package(&self, id: PackageId, file: &Path) -> Option<String> {
let base = self.dirs.iter().find(|(package, _)| *package == id)?.1.as_str();
let path = file.to_string_lossy().replace('\\', "/");
if base.is_empty() {
return Some(path);
}
path.strip_prefix(base)?.strip_prefix('/').map(str::to_owned)
}
fn host(&self, workspace: &Workspace, fs: &dyn FileSystem, id: PackageId, dir: &str) -> Option<Host> {
let (package, target) = placed_dir(&self.dirs, id, dir)?;
let package_dir = self
.dirs
.iter()
.find(|(candidate, _)| *candidate == package)
.map_or("", |(_, dir)| dir.as_str());
let project = project_between(fs, &workspace.root, package_dir, &target);
if package == id && project.is_none() {
return None;
}
let shown = |dir: &str| if dir.is_empty() { ".".to_owned() } else { dir.to_owned() };
let runs_in = match &project {
Some((abs, manifest)) => {
let dir = abs
.strip_prefix(&workspace.root)
.map_or_else(|_| abs.to_string_lossy(), |rel| rel.to_string_lossy())
.replace('\\', "/");
let name = manifest
.get("name")
.and_then(Value::as_str)
.map_or_else(|| dir.clone(), str::to_owned);
RunsIn { dir, name }
}
None => RunsIn {
dir: shown(package_dir),
name: workspace
.package(package)
.map_or_else(|| shown(package_dir), |found| found.display_name().to_owned()),
},
};
Some(Host {
package,
project,
runs_in,
})
}
fn command_binary(
&mut self,
(workspace, setup, fs): (&Workspace, &Setup, &dyn FileSystem),
id: PackageId,
binary: CommandBinary<'_>,
) {
let name = binary.name;
let Some(host) = binary.dir.and_then(|dir| self.host(workspace, fs, id, dir)) else {
if binary.filtered {
return;
}
match binary.origin {
_ if !binary.judged => {
self.provide_binary_or_ancestor(id, name);
}
Origin::File(file) => self.run_binary(setup, id, name, file),
Origin::Script(script) => {
if !self.provide_binary_or_ancestor(id, name) && !setup.ignores_binary(id, name) {
self.push_unlisted(UnlistedBinary {
package: id,
name: name.to_owned(),
script: script.to_owned(),
file: None,
runs_in: None,
});
}
}
}
return;
};
let provided = host
.project
.as_ref()
.is_some_and(|(dir, manifest)| self.provide_project_binary(fs, dir, manifest, name))
|| self.provide_binary_or_ancestor(host.package, name);
if provided
|| !binary.judged
|| setup.ignores_binary(id, name)
|| setup.ignores_binary(host.package, name)
|| host.has_script(workspace, name)
{
return;
}
let (script, file) = match binary.origin {
Origin::Script(script) => (script.to_owned(), None),
Origin::File(file) => (String::new(), Some(file.to_path_buf())),
};
self.push_unlisted(UnlistedBinary {
package: id,
name: name.to_owned(),
script,
file,
runs_in: Some(host.runs_in),
});
}
fn provide_project_binary(&mut self, fs: &dyn FileSystem, dir: &Path, manifest: &Value, bin: &str) -> bool {
let mut cache = HashMap::default();
let providers: Vec<String> = listed_names(manifest)
.filter(|name| {
listed_bins(fs, dir, name, &mut cache)
.iter()
.any(|provided| provided == bin)
})
.map(str::to_owned)
.collect();
for provider in &providers {
self.outside_used.insert((dir.to_path_buf(), provider.clone()));
}
!providers.is_empty()
}
fn run_binary(&mut self, setup: &Setup, id: PackageId, name: &str, file: &Path) {
if self.provide_binary(id, name) || self.ancestor_bins.contains_key(name) || setup.ignores_binary(id, name) {
return;
}
self.push_unlisted_binary(id, name, file);
}
fn push_unlisted_binary(&mut self, id: PackageId, name: &str, file: &Path) {
self.push_unlisted(UnlistedBinary {
package: id,
name: name.to_owned(),
script: String::new(),
file: Some(file.to_path_buf()),
runs_in: None,
});
}
fn push_unlisted(&mut self, binary: UnlistedBinary) {
if !self.unlisted_binaries.contains(&binary) {
self.unlisted_binaries.push(binary);
}
}
fn provide_binary_or_ancestor(&mut self, id: PackageId, bin: &str) -> bool {
if self.provide_binary(id, bin) {
return true;
}
let Some(providers) = self.ancestor_bins.get(bin) else {
return false;
};
self.external_used.extend(providers.iter().cloned());
true
}
fn provide_binary(&mut self, id: PackageId, bin: &str) -> bool {
let mut provided: Vec<(Listed, String)> = Vec::new();
let scopes = std::iter::once(id).chain(self.ancestors[id.index()].iter().copied());
for scope in scopes {
if let Some(deps) = self.bins[scope.index()].get(bin) {
provided.extend(deps.iter().map(|dep| (Listed::Package(scope), dep.clone())));
}
provided.extend(
self.listed[scope.index()]
.iter()
.filter(|dep| {
(unscoped(dep) == bin && !self.known_bins[scope.index()].contains(&***dep))
|| BINARY_PACKAGES
.iter()
.any(|(b, package)| *b == bin && *package == &***dep)
})
.map(|dep| (Listed::Package(scope), dep.to_string())),
);
}
if provided.is_empty() {
let scopes = std::iter::once(id).chain(self.ancestors[id.index()].iter().copied());
for scope in scopes {
provided.extend(
self.listed[scope.index()]
.iter()
.filter(|dep| !self.known_bins[scope.index()].contains(&***dep) && resembles(dep, bin))
.map(|dep| (Listed::Package(scope), dep.to_string())),
);
}
}
if let Some(members) = self.member_bins.get(bin).cloned() {
for (member_id, member) in members {
if let Some(found) = self.find_listed(id, &member) {
provided.push((found, member));
} else if self.member_bins_hoisted || member_id == id {
provided.push((Listed::Package(id), String::new()));
}
}
}
let found = !provided.is_empty() || (self.rush && RUSH_COMMANDS.contains(&bin));
self.with_via(Use::Binary(bin.to_owned()), |usage| {
for (scope, dep) in provided {
if !dep.is_empty() {
usage.reference(scope, &dep);
}
}
});
found
}
#[must_use]
pub fn is_referenced(&self, id: PackageId, name: &str) -> bool {
self.credit(id, name).is_some()
}
pub(crate) fn credit(&self, id: PackageId, name: &str) -> Option<Credit<'_>> {
if ALWAYS_USED.contains(&name) {
return Some(Credit::AlwaysUsed);
}
self.credit_inner(id, name, &mut HashSet::default())
}
fn credit_inner(&self, id: PackageId, name: &str, visited: &mut HashSet<String>) -> Option<Credit<'_>> {
let referenced = &self.referenced[id.index()];
if let Some(uses) = referenced.get(name) {
return Some(Credit::Uses(uses));
}
if !visited.insert(name.to_owned()) {
return None;
}
let typed = typed_package(name);
if let Some(typed) = &typed {
if UNPAIRED_TYPES.contains(&typed.as_str()) {
return Some(Credit::UnpairedTypes);
}
if referenced.contains_key(typed) {
return Some(Credit::TypesOf(typed.clone()));
}
}
let hosts = &self.hosts[id.index()];
std::iter::once(name)
.chain(typed.as_deref())
.filter_map(|candidate| hosts.get(candidate))
.flatten()
.find(|host| self.credit_inner(id, host, visited).is_some())
.map(|host| Credit::PeerOf(host.clone()))
}
pub(crate) fn uses(&self, id: PackageId, name: &str) -> Option<&BTreeSet<Use>> {
self.referenced[id.index()].get(name)
}
#[must_use]
pub fn was_imported(&self, id: PackageId, name: &str) -> bool {
self.referenced[id.index()].contains_key(name)
}
#[must_use]
pub fn is_listed(&self, id: PackageId, name: &str) -> bool {
self.listed[id.index()].contains(name)
}
#[must_use]
pub fn ancestor_uses(&self) -> Vec<(PathBuf, String)> {
self.external_used
.iter()
.filter_map(|name| Some((self.external_listed.get(name)?.clone(), name.clone())))
.chain(self.outside_used.iter().cloned())
.collect()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::path::{Path, PathBuf};
use skif_config::Config;
use skif_core::{MemoryFs, Span};
use skif_workspace::{PackageId, Workspace};
use super::{SharedInstalled, UnlistedConfigDependency, UnlistedConfigIndex, package_ancestors};
#[test]
fn shared_installed_manifests_are_read_once_and_only_where_node_modules_is() {
let fs = MemoryFs::new()
.with_file(
"/ws/node_modules/a/package.json",
r#"{"name":"a","bin":"cli.js","version":"1.0.0"}"#,
)
.with_file(
"/ws/packages/q/node_modules/a/package.json",
r#"{"name":"a","version":"2.0.0"}"#,
);
let shared = SharedInstalled::default();
let root = Path::new("/ws");
let from_p = shared.read(&fs, Path::new("/ws/packages/p"), root, "a").unwrap();
let again = shared.read(&fs, Path::new("/ws/packages/p"), root, "a").unwrap();
let from_q = shared.read(&fs, Path::new("/ws/packages/q"), root, "a").unwrap();
assert_eq!(from_p.bins, ["a"]);
assert_eq!(from_p.version.as_deref(), Some("1.0.0"));
assert!(std::sync::Arc::ptr_eq(&from_p, &again));
assert_eq!(from_q.version.as_deref(), Some("2.0.0"));
assert!(shared.read(&fs, Path::new("/ws/packages/p"), root, "missing").is_none());
let manifests = shared.manifests.lock().unwrap();
let mut read: Vec<&Path> = manifests.keys().map(PathBuf::as_path).collect();
read.sort_unstable();
assert_eq!(
read,
[
Path::new("/ws/node_modules/a/package.json"),
Path::new("/ws/node_modules/missing/package.json"),
Path::new("/ws/packages/q/node_modules/a/package.json"),
]
);
}
#[test]
fn ancestors_are_the_containing_packages_innermost_first_then_the_root() {
let fs = MemoryFs::new()
.with_file(
"/ws/package.json",
r#"{"name":"r","workspaces":["packages/a","packages/a/sub","packages/a/sub/deep","packages/ab"]}"#,
)
.with_file("/ws/packages/a/package.json", r#"{"name":"a"}"#)
.with_file("/ws/packages/a/sub/package.json", r#"{"name":"sub"}"#)
.with_file("/ws/packages/a/sub/deep/package.json", r#"{"name":"deep"}"#)
.with_file("/ws/packages/ab/package.json", r#"{"name":"ab"}"#);
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(Path::new("/ws"), &config, &fs).unwrap();
let ancestors = package_ancestors(&workspace, false);
let name = |id: PackageId| workspace.package(id).and_then(|package| package.name.clone()).unwrap();
let of = |wanted: &str| {
let (id, _) = workspace
.iter()
.find(|(_, package)| package.name.as_deref() == Some(wanted))
.unwrap();
ancestors[id.index()]
.iter()
.map(|&other| name(other))
.collect::<Vec<_>>()
};
assert_eq!(of("deep"), ["sub", "a", "r"]);
assert_eq!(of("sub"), ["a", "r"]);
assert_eq!(of("a"), ["r"]);
assert_eq!(of("ab"), ["r"]);
assert_eq!(of("r"), [] as [std::string::String; 0]);
assert!(package_ancestors(&workspace, true).iter().all(Vec::is_empty));
}
#[test]
fn unlisted_config_entries_are_kept_once_per_package_name_and_file_with_each_reader_once() {
let (a, b, c) = (
PackageId::from_index(1),
PackageId::from_index(2),
PackageId::from_index(3),
);
let (first, second) = (Span::new(10, 15, 2, 3, 2, 8), Span::new(40, 45, 5, 1, 5, 6));
let mut list = Vec::new();
let mut index = UnlistedConfigIndex::default();
let mut record = |package, name, file: &str, span, reader| {
index.record(&mut list, package, name, PathBuf::from(file), span, reader);
};
record(a, "x", "tsconfig.base.json", None, Some(b));
record(a, "y", "tsconfig.base.json", None, None);
record(a, "x", "tsconfig.base.json", Some(first), Some(c));
record(a, "x", "tsconfig.base.json", Some(second), Some(b));
record(a, "x", "other.json", None, None);
record(b, "x", "tsconfig.base.json", Some(second), None);
record(a, "x", "tsconfig.base.json", None, None);
let entry = |package, name: &str, file: &str, span, readers: Vec<PackageId>| UnlistedConfigDependency {
package,
name: name.to_owned(),
file: PathBuf::from(file),
span,
readers,
};
assert_eq!(
list,
[
entry(a, "x", "tsconfig.base.json", Some(first), vec![b, c]),
entry(a, "y", "tsconfig.base.json", None, vec![]),
entry(a, "x", "other.json", None, vec![]),
entry(b, "x", "tsconfig.base.json", Some(second), vec![]),
]
);
}
}