use std::path::{Path, PathBuf};
use skif_core::FileSystem;
use skif_js_plugins::bazel::{public_hoist_packages, repository_root_within};
use skif_workspace::{Workspace, npmrc_entries};
const BAZEL_FILES: [&str; 3] = ["MODULE.bazel", "WORKSPACE.bazel", "WORKSPACE"];
#[derive(Debug, Clone)]
pub(crate) struct PublicHoisting {
patterns: Vec<String>,
bazel: Vec<(PathBuf, String)>,
}
impl PublicHoisting {
pub(crate) fn read(
workspace: &Workspace,
pnpm: Option<bool>,
git_root: Option<&Path>,
fs: &dyn FileSystem,
) -> Self {
Self {
patterns: pnpm.map_or_default(|reads_npmrc| pnpm_patterns(workspace, reads_npmrc, fs)),
bazel: bazel_hoisted(workspace, git_root, fs),
}
}
pub(crate) fn hoists(&self, dir: &Path, name: &str) -> bool {
matches_patterns(&self.patterns, name)
|| self
.bazel
.iter()
.any(|(at, hoisted)| hoisted == name && dir.starts_with(at))
}
}
fn pnpm_patterns(workspace: &Workspace, reads_npmrc: bool, fs: &dyn FileSystem) -> Vec<String> {
let yaml = workspace.pnpm_workspace.as_ref();
let npmrc = reads_npmrc
.then(|| fs.read_to_string(&workspace.root.join(".npmrc")).ok())
.flatten()
.unwrap_or_default();
let shamefully = yaml
.and_then(|yaml| yaml.shamefully_hoist)
.or_else(|| npmrc_flag(&npmrc, "shamefully-hoist"));
if shamefully == Some(true) {
return vec!["*".to_owned()];
}
yaml.and_then(|yaml| yaml.public_hoist_pattern.clone())
.unwrap_or_else(|| npmrc_list(&npmrc, "public-hoist-pattern"))
}
fn npmrc_flag(npmrc: &str, key: &str) -> Option<bool> {
let value = npmrc_entries(npmrc).filter(|(entry, _)| *entry == key).last()?.1;
match value {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn npmrc_list(npmrc: &str, key: &str) -> Vec<String> {
let array_key = format!("{key}[]");
let mut values = Vec::new();
let mut is_list = false;
for (entry, value) in npmrc_entries(npmrc) {
if entry == array_key {
is_list = true;
} else if entry != key {
continue;
} else if !is_list {
values.clear();
}
values.push(value.to_owned());
}
values
}
fn matches_patterns(patterns: &[String], name: &str) -> bool {
if !patterns.is_empty() && patterns.iter().all(|pattern| pattern.starts_with('!')) {
return !patterns.iter().any(|pattern| {
pattern
.strip_prefix('!')
.is_some_and(|excluded| wildcard(excluded, name))
});
}
patterns
.iter()
.fold(false, |hoisted, pattern| match pattern.strip_prefix('!') {
Some(excluded) => hoisted && !wildcard(excluded, name),
None => hoisted || wildcard(pattern, name),
})
}
fn wildcard(pattern: &str, name: &str) -> bool {
let mut parts = pattern.split('*');
let Some(mut rest) = name.strip_prefix(parts.next().unwrap_or_default()) else {
return false;
};
let parts: Vec<&str> = parts.collect();
let Some((last, middle)) = parts.split_last() else {
return rest.is_empty();
};
for part in middle {
let Some(at) = rest.find(part) else {
return false;
};
rest = &rest[at + part.len()..];
}
rest.ends_with(last)
}
fn bazel_hoisted(workspace: &Workspace, git_root: Option<&Path>, fs: &dyn FileSystem) -> Vec<(PathBuf, String)> {
let Some(root) = repository_root_within(fs, &workspace.root, git_root) else {
return Vec::new();
};
let mut hoisted = Vec::new();
for file in BAZEL_FILES {
let Ok(source) = fs.read_to_string(&root.join(file)) else {
continue;
};
for (package, locations) in public_hoist_packages(&source) {
let name = without_version(&package);
for location in locations {
let location = location.trim_start_matches("//");
let dir = if location.is_empty() {
root.clone()
} else {
root.join(location)
};
hoisted.push((dir, name.to_owned()));
}
}
}
hoisted
}
fn without_version(key: &str) -> &str {
let scoped = usize::from(key.starts_with('@'));
key[scoped..].find('@').map_or(key, |at| &key[..scoped + at])
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(patterns: &[&str]) -> Vec<String> {
patterns.iter().map(|pattern| (*pattern).to_owned()).collect()
}
#[test]
fn patterns_match_as_pnpm_s_matcher_does() {
for (patterns, name, hoisted) in [
(&[][..], "a", false),
(&["*"][..], "@scope/a", true),
(&["a"][..], "a", true),
(&["a"][..], "ab", false),
(&["*eslint*"][..], "eslint-plugin-x", true),
(&["*eslint*"][..], "@typescript-eslint/parser", true),
(&["*eslint*"][..], "prettier", false),
(&["@scope/*"][..], "@scope/a", true),
(&["@scope/*"][..], "@other/a", false),
(&["a*b*c"][..], "aXbYc", true),
(&["a*b*c"][..], "abc", true),
(&["a*b*c"][..], "acb", false),
(&["a.b"][..], "aXb", false),
(&["!a"][..], "a", false),
(&["!a"][..], "b", true),
(&["!a", "!b"][..], "c", true),
(&["*", "!a"][..], "a", false),
(&["*", "!a"][..], "b", true),
(&["!a", "a"][..], "a", true),
(&["a", "!a", "a"][..], "a", true),
(&["b", "!a"][..], "c", false),
] {
assert_eq!(matches_patterns(&owned(patterns), name), hoisted, "{patterns:?} {name}");
}
}
#[test]
fn npmrc_lists_build_as_ini_builds_them() {
for (npmrc, values) in [
("", &[][..]),
("p[]=a\np[]=b\n", &["a", "b"][..]),
("p=a\n", &["a"][..]),
("p=a\np=b\n", &["b"][..]),
("p=a\np[]=b\n", &["a", "b"][..]),
("p[]=a\np=b\n", &["a", "b"][..]),
("other[]=a\n", &[][..]),
("[section]\np[]=a\n", &[][..]),
] {
assert_eq!(npmrc_list(npmrc, "p"), owned(values), "{npmrc:?}");
}
}
#[test]
fn npmrc_flags_are_the_last_boolean_entry() {
assert_eq!(npmrc_flag("shamefully-hoist=true\n", "shamefully-hoist"), Some(true));
assert_eq!(
npmrc_flag("shamefully-hoist=true\nshamefully-hoist=false\n", "shamefully-hoist"),
Some(false)
);
assert_eq!(npmrc_flag("shamefully-hoist=yes\n", "shamefully-hoist"), None);
assert_eq!(npmrc_flag("", "shamefully-hoist"), None);
}
#[test]
fn versions_are_dropped_from_hoisted_keys() {
for (key, name) in [
("a", "a"),
("a@1.0.0", "a"),
("@scope/a", "@scope/a"),
("@scope/a@1.0.0", "@scope/a"),
("", ""),
] {
assert_eq!(without_version(key), name, "{key}");
}
}
}