#[expect(
clippy::disallowed_types,
reason = "package.json scripts are deserialized as std HashMap"
)]
use std::collections::HashMap;
#[derive(Debug)]
pub struct WorkspacePackage {
name: String,
dir: String,
root: bool,
#[expect(
clippy::disallowed_types,
reason = "package.json scripts are deserialized as std HashMap"
)]
scripts: HashMap<String, String>,
}
impl WorkspacePackage {
pub fn dir(&self) -> &str {
&self.dir
}
#[expect(
clippy::disallowed_types,
reason = "package.json scripts are deserialized as std HashMap"
)]
pub const fn scripts(&self) -> &HashMap<String, String> {
&self.scripts
}
}
#[derive(Debug, Default)]
pub struct WorkspacePackages {
packages: Vec<WorkspacePackage>,
}
impl WorkspacePackages {
#[expect(
clippy::disallowed_types,
reason = "package.json scripts are deserialized as std HashMap"
)]
pub fn add(&mut self, name: &str, dir: &str, scripts: Option<&HashMap<String, String>>) {
self.push(name, dir, scripts, false);
}
#[expect(
clippy::disallowed_types,
reason = "package.json scripts are deserialized as std HashMap"
)]
pub fn add_root(&mut self, name: &str, scripts: Option<&HashMap<String, String>>) {
self.push(name, "", scripts, true);
}
#[expect(
clippy::disallowed_types,
reason = "package.json scripts are deserialized as std HashMap"
)]
fn push(
&mut self,
name: &str,
dir: &str,
scripts: Option<&HashMap<String, String>>,
root: bool,
) {
self.packages.push(WorkspacePackage {
name: name.to_string(),
dir: normalize_dir(dir).unwrap_or_default(),
root,
scripts: scripts.cloned().unwrap_or_default(),
});
}
pub fn iter(&self) -> impl Iterator<Item = &WorkspacePackage> {
self.packages.iter()
}
pub fn find_dir(&self, dir: &str) -> Option<&WorkspacePackage> {
self.packages.iter().find(|package| package.dir == dir)
}
pub fn select(
&self,
selectors: &[PackageSelector],
package_dir: &str,
) -> Vec<&WorkspacePackage> {
let matches = |selector: &PackageSelector, package: &WorkspacePackage| {
selector.kind.matches(package, package_dir)
};
let including: Vec<&PackageSelector> = selectors
.iter()
.filter(|selector| {
!selector.exclude
&& !matches!(selector.kind, SelectorKind::All | SelectorKind::IncludeRoot)
})
.collect();
let include_root = selectors
.iter()
.any(|selector| !selector.exclude && selector.kind == SelectorKind::IncludeRoot);
self.packages
.iter()
.filter(|package| {
let included = if including.is_empty() {
!package.root || include_root
} else {
including.iter().any(|selector| matches(selector, package))
};
included
&& !selectors
.iter()
.any(|selector| selector.exclude && matches(selector, package))
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageSelector {
kind: SelectorKind,
exclude: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SelectorKind {
All,
IncludeRoot,
Root,
Name(String),
NameOrProjectDir(String),
Dir(String),
NameOrDirPrefix(String),
Unsupported,
}
impl SelectorKind {
fn matches(&self, package: &WorkspacePackage, package_dir: &str) -> bool {
match self {
Self::Name(pattern) => {
!package.name.is_empty() && glob_matches(pattern, &package.name, false)
}
Self::NameOrProjectDir(pattern) => {
let dir = if package.dir.is_empty() {
"."
} else {
&package.dir
};
(!package.name.is_empty() && glob_matches(pattern, &package.name, false))
|| glob_matches(pattern, dir, true)
}
Self::Dir(pattern) => join_dir(package_dir, pattern)
.is_some_and(|pattern| glob_matches(&pattern, &package.dir, true)),
Self::NameOrDirPrefix(_) if package.root => false,
Self::NameOrDirPrefix(value) => {
package.name == *value
|| join_dir(package_dir, value).is_some_and(|dir| {
package.dir == dir
|| dir.is_empty()
|| package
.dir
.strip_prefix(&dir)
.is_some_and(|rest| rest.starts_with('/'))
})
}
Self::All | Self::IncludeRoot => true,
Self::Root => package.root,
Self::Unsupported => false,
}
}
}
impl PackageSelector {
pub const fn all() -> Self {
Self {
kind: SelectorKind::All,
exclude: false,
}
}
pub const fn include_root() -> Self {
Self {
kind: SelectorKind::IncludeRoot,
exclude: false,
}
}
pub const fn root() -> Self {
Self {
kind: SelectorKind::Root,
exclude: false,
}
}
pub fn directory(dir: &str) -> Self {
Self {
kind: SelectorKind::Dir(escape_glob(strip_quotes(dir))),
exclude: false,
}
}
pub fn yarn_foreach_glob(glob: &str, exclude: bool) -> Self {
Self {
kind: SelectorKind::NameOrProjectDir(strip_quotes(glob).to_string()),
exclude,
}
}
pub fn yarn_workspace(name: &str) -> Self {
Self {
kind: SelectorKind::Name(escape_glob(strip_quotes(name))),
exclude: false,
}
}
pub fn npm_workspace(value: &str) -> Self {
Self {
kind: SelectorKind::NameOrDirPrefix(strip_quotes(value).to_string()),
exclude: false,
}
}
pub fn npm_workspaces() -> Self {
Self {
kind: SelectorKind::NameOrDirPrefix(".".to_string()),
exclude: false,
}
}
pub fn pnpm_filter(value: &str) -> Self {
let value = strip_quotes(value);
let (exclude, value) = value
.strip_prefix('!')
.map_or((false, value), |rest| (true, rest));
let kind = if value.is_empty()
|| value.contains("...")
|| value.contains('^')
|| value.contains('[')
{
SelectorKind::Unsupported
} else if let Some(dir) = value.strip_prefix('{').and_then(|v| v.strip_suffix('}')) {
SelectorKind::Dir(dir.to_string())
} else if value.starts_with("./") || value.starts_with("../") || value == "." {
SelectorKind::Dir(value.to_string())
} else {
SelectorKind::Name(value.to_string())
};
Self { kind, exclude }
}
}
fn strip_quotes(value: &str) -> &str {
for quote in ['\'', '"'] {
if let Some(inner) = value
.strip_prefix(quote)
.and_then(|rest| rest.strip_suffix(quote))
{
return inner;
}
}
value
}
fn escape_glob(name: &str) -> String {
globset::escape(name)
}
fn glob_matches(pattern: &str, value: &str, literal_separator: bool) -> bool {
globset::GlobBuilder::new(pattern)
.literal_separator(literal_separator)
.build()
.is_ok_and(|glob| glob.compile_matcher().is_match(value))
}
fn join_dir(base: &str, path: &str) -> Option<String> {
if base.is_empty() {
normalize_dir(path)
} else {
normalize_dir(&format!("{base}/{path}"))
}
}
fn normalize_dir(path: &str) -> Option<String> {
let mut segments: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
Some(segments.join("/"))
}
pub fn relative_dir(from: &str, to: &str) -> String {
let from: Vec<&str> = from.split('/').filter(|s| !s.is_empty()).collect();
let to: Vec<&str> = to.split('/').filter(|s| !s.is_empty()).collect();
let common = from
.iter()
.zip(&to)
.take_while(|(left, right)| left == right)
.count();
let mut parts: Vec<&str> = vec![".."; from.len() - common];
parts.extend_from_slice(&to[common..]);
if parts.is_empty() {
".".to_string()
} else {
parts.join("/")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn packages() -> WorkspacePackages {
let mut packages = WorkspacePackages::default();
packages.add("web", "packages/web", None);
packages.add("@acme/api", "./packages/api/", None);
packages.add("docs", "apps/docs", None);
packages
}
fn dirs(selectors: &[PackageSelector], package_dir: &str) -> Vec<String> {
packages()
.select(selectors, package_dir)
.iter()
.map(|package| package.dir().to_string())
.collect()
}
#[test]
fn pnpm_filters_select_by_name_glob_and_directory() {
let filter = PackageSelector::pnpm_filter;
assert_eq!(dirs(&[filter("web")], ""), ["packages/web"]);
assert_eq!(dirs(&[filter("'@acme/*'")], ""), ["packages/api"]);
assert_eq!(
dirs(&[filter("./packages/*")], ""),
["packages/web", "packages/api"]
);
assert_eq!(dirs(&[filter("{apps/docs}")], ""), ["apps/docs"]);
assert_eq!(dirs(&[filter("../web")], "packages/api"), ["packages/web"]);
assert_eq!(
dirs(&[filter("./packages/*"), filter("!web")], ""),
["packages/api"]
);
assert_eq!(
dirs(&[filter("!web")], ""),
["packages/api", "apps/docs"],
"an excluding filter alone selects every other package"
);
for unsupported in ["web...", "...web", "web^...", "[origin/main]"] {
assert!(dirs(&[filter(unsupported)], "").is_empty(), "{unsupported}");
}
}
#[test]
fn every_package_is_narrowed_by_other_selectors() {
let all = PackageSelector::all;
assert_eq!(
dirs(&[all()], ""),
["packages/web", "packages/api", "apps/docs"]
);
assert_eq!(
dirs(&[all(), PackageSelector::pnpm_filter("web")], ""),
["packages/web"]
);
assert_eq!(
dirs(
&[all(), PackageSelector::yarn_foreach_glob("*o*", true)],
""
),
["packages/web", "packages/api"]
);
assert!(dirs(&[all(), PackageSelector::pnpm_filter("web...")], "").is_empty());
}
#[test]
fn a_directory_selects_the_package_in_it() {
let directory = PackageSelector::directory;
assert_eq!(dirs(&[directory("packages/web")], ""), ["packages/web"]);
assert_eq!(
dirs(&[directory("../../apps/docs")], "packages/api"),
["apps/docs"]
);
assert!(dirs(&[directory("packages")], "").is_empty());
assert!(dirs(&[directory("packages/*")], "").is_empty());
}
#[test]
fn npm_workspaces_select_by_name_or_directory() {
let workspace = PackageSelector::npm_workspace;
assert_eq!(dirs(&[workspace("web")], ""), ["packages/web"]);
assert_eq!(dirs(&[workspace("packages/web")], ""), ["packages/web"]);
assert_eq!(
dirs(&[workspace("packages")], ""),
["packages/web", "packages/api"]
);
assert!(dirs(&[workspace("pack")], "").is_empty());
assert!(
dirs(&[workspace("packages/web")], "packages/api").is_empty(),
"npm resolves a workspace path against the calling package"
);
assert_eq!(
dirs(&[workspace("../web")], "packages/api"),
["packages/web"],
"npm resolves a workspace path against the calling package"
);
assert_eq!(
dirs(&[PackageSelector::npm_workspaces()], "packages/api"),
["packages/api"],
"`--workspaces` in a workspace package selects that package"
);
}
#[test]
fn yarn_workspace_selects_an_exact_name() {
assert_eq!(
dirs(&[PackageSelector::yarn_workspace("@acme/api")], ""),
["packages/api"]
);
assert!(dirs(&[PackageSelector::yarn_workspace("*")], "").is_empty());
}
fn dirs_with_root(selectors: &[PackageSelector], package_dir: &str) -> Vec<String> {
let mut packages = packages();
packages.add_root("monorepo", None);
packages
.select(selectors, package_dir)
.iter()
.map(|package| package.dir().to_string())
.collect()
}
#[test]
fn the_root_package_is_selected_where_the_package_manager_selects_it() {
let all = PackageSelector::all;
let workspaces = ["packages/web", "packages/api", "apps/docs"];
assert_eq!(
dirs_with_root(&[all()], ""),
workspaces,
"`pnpm -r`, `npm -ws`, and `yarn workspaces run` leave out the root"
);
assert_eq!(
dirs_with_root(&[all(), PackageSelector::include_root()], ""),
["packages/web", "packages/api", "apps/docs", ""],
"`yarn workspaces foreach -A` includes the root workspace"
);
assert_eq!(
dirs_with_root(
&[
all(),
PackageSelector::include_root(),
PackageSelector::yarn_foreach_glob("web", false)
],
""
),
["packages/web"]
);
assert_eq!(
dirs_with_root(
&[
all(),
PackageSelector::include_root(),
PackageSelector::yarn_foreach_glob(".", true)
],
""
),
workspaces,
"yarn matches `--exclude` against the directory too"
);
assert_eq!(
dirs_with_root(&[PackageSelector::root()], "packages/web"),
[""]
);
assert_eq!(
dirs_with_root(&[PackageSelector::yarn_workspace("monorepo")], ""),
[""]
);
assert_eq!(
dirs_with_root(&[PackageSelector::pnpm_filter("monorepo")], ""),
[""]
);
assert_eq!(
dirs_with_root(&[PackageSelector::pnpm_filter("{.}")], ""),
[""]
);
assert_eq!(
dirs_with_root(&[PackageSelector::directory("../..")], "packages/web"),
[""]
);
assert!(
dirs_with_root(&[PackageSelector::npm_workspace("monorepo")], "").is_empty(),
"npm finds no workspace with the name of the root package"
);
assert_eq!(
dirs_with_root(&[PackageSelector::npm_workspaces()], ""),
["packages/web", "packages/api", "apps/docs"],
"an npm workspace selection alone leaves out the root"
);
assert_eq!(
dirs_with_root(
&[
PackageSelector::npm_workspace("web"),
PackageSelector::root()
],
""
),
["packages/web", ""],
"npm `--include-workspace-root` adds the root to a workspace selection"
);
assert_eq!(
dirs_with_root(
&[
PackageSelector::pnpm_filter("!web"),
PackageSelector::include_root()
],
""
),
["packages/api", "apps/docs", ""],
"pnpm `--include-workspace-root` adds the root to an excluding filter"
);
}
#[test]
fn yarn_foreach_globs_match_names_or_directories() {
let include = |glob| PackageSelector::yarn_foreach_glob(glob, false);
assert_eq!(
dirs(&[PackageSelector::all(), include("packages/*")], ""),
["packages/web", "packages/api"]
);
assert_eq!(
dirs(&[PackageSelector::all(), include("@acme/*")], ""),
["packages/api"]
);
}
#[test]
fn relative_dirs_climb_to_the_common_parent() {
assert_eq!(relative_dir("", "packages/web"), "packages/web");
assert_eq!(relative_dir("packages/api", "packages/web"), "../web");
assert_eq!(
relative_dir("apps/docs", "packages/web"),
"../../packages/web"
);
assert_eq!(relative_dir("packages/web", "packages/web"), ".");
assert_eq!(relative_dir("packages/web", ""), "../..");
}
}