use std::path::{Path, PathBuf};
use fallow_config::{PackageJson, ResolvedConfig, WorkspaceInfo, parse_pnpm_catalog_data};
use fallow_types::results::UnusedCatalogEntry;
use rustc_hash::FxHashSet;
const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
pub fn find_unused_catalog_entries(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Vec<UnusedCatalogEntry> {
let yaml_path = config.root.join(PNPM_WORKSPACE_FILE);
let Ok(yaml_source) = std::fs::read_to_string(&yaml_path) else {
return Vec::new();
};
let data = parse_pnpm_catalog_data(&yaml_source);
if data.catalogs.is_empty() {
return Vec::new();
}
let consumer_pkg_paths = collect_consumer_pkg_paths(config, workspaces);
let consumers = collect_catalog_consumers(&consumer_pkg_paths, &config.root);
let mut findings = Vec::new();
for catalog in &data.catalogs {
for entry in &catalog.entries {
let key = ConsumerKey {
package_name: entry.package_name.as_str(),
catalog_name: catalog.name.as_str(),
};
if consumers.references.contains(&key.owned()) {
continue;
}
let hardcoded_consumers = consumers
.hardcoded
.iter()
.filter(|(name, _)| name == &entry.package_name)
.map(|(_, path)| path.clone())
.collect();
findings.push(UnusedCatalogEntry {
entry_name: entry.package_name.clone(),
catalog_name: catalog.name.clone(),
path: PathBuf::from(PNPM_WORKSPACE_FILE),
line: entry.line,
hardcoded_consumers,
});
}
}
findings
}
fn collect_consumer_pkg_paths(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Vec<PathBuf> {
let mut paths = Vec::with_capacity(workspaces.len() + 1);
paths.push(config.root.join("package.json"));
for ws in workspaces {
paths.push(ws.root.join("package.json"));
}
paths
}
#[derive(Debug, Default)]
struct CatalogConsumers {
references: FxHashSet<OwnedConsumerKey>,
hardcoded: Vec<(String, PathBuf)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct OwnedConsumerKey {
package_name: String,
catalog_name: String,
}
#[derive(Debug, Clone, Copy)]
struct ConsumerKey<'a> {
package_name: &'a str,
catalog_name: &'a str,
}
impl ConsumerKey<'_> {
fn owned(self) -> OwnedConsumerKey {
OwnedConsumerKey {
package_name: self.package_name.to_string(),
catalog_name: self.catalog_name.to_string(),
}
}
}
fn collect_catalog_consumers(pkg_paths: &[PathBuf], root: &Path) -> CatalogConsumers {
let mut consumers = CatalogConsumers::default();
for pkg_path in pkg_paths {
let Ok(pkg) = PackageJson::load(pkg_path) else {
continue;
};
let relative_path = pkg_path
.strip_prefix(root)
.map_or_else(|_| pkg_path.clone(), Path::to_path_buf);
for deps in [
pkg.dependencies.as_ref(),
pkg.dev_dependencies.as_ref(),
pkg.peer_dependencies.as_ref(),
pkg.optional_dependencies.as_ref(),
]
.into_iter()
.flatten()
{
for (name, version) in deps {
if let Some(catalog) = parse_catalog_reference(version) {
consumers.references.insert(OwnedConsumerKey {
package_name: name.clone(),
catalog_name: catalog.to_string(),
});
} else if is_hardcoded_version(version) {
consumers
.hardcoded
.push((name.clone(), relative_path.clone()));
}
}
}
}
consumers
}
fn parse_catalog_reference(value: &str) -> Option<&str> {
let rest = value.strip_prefix("catalog:")?;
if rest.is_empty() || rest == "default" {
Some("default")
} else {
Some(rest)
}
}
fn is_hardcoded_version(value: &str) -> bool {
!(value.starts_with("workspace:")
|| value.starts_with("file:")
|| value.starts_with("link:")
|| value.starts_with("portal:"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bare_catalog_as_default() {
assert_eq!(parse_catalog_reference("catalog:"), Some("default"));
assert_eq!(parse_catalog_reference("catalog:default"), Some("default"));
}
#[test]
fn parses_named_catalog() {
assert_eq!(parse_catalog_reference("catalog:react17"), Some("react17"));
}
#[test]
fn non_catalog_versions_return_none() {
assert_eq!(parse_catalog_reference("^18.2.0"), None);
assert_eq!(parse_catalog_reference("workspace:*"), None);
assert_eq!(parse_catalog_reference("npm:other-pkg@^1.0.0"), None);
assert_eq!(parse_catalog_reference(""), None);
}
#[test]
fn workspace_and_link_protocols_are_not_hardcoded() {
assert!(!is_hardcoded_version("workspace:*"));
assert!(!is_hardcoded_version("workspace:^"));
assert!(!is_hardcoded_version("workspace:~"));
assert!(!is_hardcoded_version("file:../other-pkg"));
assert!(!is_hardcoded_version("link:../symlinked"));
assert!(!is_hardcoded_version("portal:../portal"));
}
#[test]
fn semver_ranges_and_npm_specs_are_hardcoded() {
assert!(is_hardcoded_version("^1.0.0"));
assert!(is_hardcoded_version("~2.5.0"));
assert!(is_hardcoded_version("1.2.3"));
assert!(is_hardcoded_version(">=1.0.0 <2.0.0"));
assert!(is_hardcoded_version("npm:other-pkg@^1.0.0"));
assert!(is_hardcoded_version("github:user/repo#commit"));
assert!(is_hardcoded_version("https://example.com/pkg.tgz"));
}
}