use std::path::{Path, PathBuf};
use fallow_config::{
CompiledIgnoreCatalogReferenceRule, PackageJson, PnpmCatalogData, ResolvedConfig,
WorkspaceInfo, parse_package_json_catalog_data, parse_pnpm_catalog_data,
};
use fallow_types::results::{EmptyCatalogGroup, UnresolvedCatalogReference, UnusedCatalogEntry};
use rustc_hash::FxHashSet;
const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
const PACKAGE_JSON_FILE: &str = "package.json";
pub struct PnpmCatalogState {
data: PnpmCatalogData,
consumers: CatalogConsumers,
source_path: PathBuf,
}
pub fn gather_pnpm_catalog_state(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Option<PnpmCatalogState> {
let yaml_path = config.root.join(PNPM_WORKSPACE_FILE);
let (data, source_path) = if let Ok(yaml_source) = std::fs::read_to_string(&yaml_path) {
(
parse_pnpm_catalog_data(&yaml_source),
PathBuf::from(PNPM_WORKSPACE_FILE),
)
} else {
let package_json_path = config.root.join(PACKAGE_JSON_FILE);
let package_json_source = std::fs::read_to_string(&package_json_path).ok()?;
let data = parse_package_json_catalog_data(&package_json_source);
if data.catalogs.is_empty() && data.empty_named_catalog_groups.is_empty() {
return None;
}
(data, PathBuf::from(PACKAGE_JSON_FILE))
};
let consumer_pkg_paths = collect_consumer_pkg_paths(config, workspaces);
let consumers = collect_catalog_consumers(&consumer_pkg_paths, &config.root);
Some(PnpmCatalogState {
data,
consumers,
source_path,
})
}
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn find_unused_catalog_entries(state: &PnpmCatalogState) -> Vec<UnusedCatalogEntry> {
if state.data.catalogs.is_empty() {
return Vec::new();
}
let mut findings = Vec::new();
for catalog in &state.data.catalogs {
for entry in &catalog.entries {
let key = ConsumerKey {
package_name: entry.package_name.as_str(),
catalog_name: catalog.name.as_str(),
};
if state.consumers.references.contains(&key.owned()) {
continue;
}
let hardcoded_consumers = state
.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: state.source_path.clone(),
line: entry.line,
hardcoded_consumers,
});
}
}
findings
}
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn find_empty_catalog_groups(state: &PnpmCatalogState) -> Vec<EmptyCatalogGroup> {
state
.data
.empty_named_catalog_groups
.iter()
.map(|group| EmptyCatalogGroup {
catalog_name: group.name.clone(),
path: state.source_path.clone(),
line: group.line,
})
.collect()
}
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_cli::programmatic::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
)]
pub fn find_unresolved_catalog_references(
state: &PnpmCatalogState,
ignore_rules: &[CompiledIgnoreCatalogReferenceRule],
root: &Path,
) -> Vec<UnresolvedCatalogReference> {
let mut findings = Vec::new();
for reference in &state.consumers.referenced_with_locations {
if catalog_has_entry(
&state.data,
&reference.catalog_name,
&reference.package_name,
) {
continue;
}
let consumer_path_str = reference
.consumer_path
.strip_prefix(root)
.unwrap_or(&reference.consumer_path)
.to_string_lossy()
.replace('\\', "/");
if ignore_rules.iter().any(|rule| {
rule.matches(
&reference.package_name,
&reference.catalog_name,
&consumer_path_str,
)
}) {
continue;
}
let available_in_catalogs = collect_available_in_catalogs(
&state.data,
&reference.package_name,
&reference.catalog_name,
);
findings.push(UnresolvedCatalogReference {
entry_name: reference.package_name.clone(),
catalog_name: reference.catalog_name.clone(),
path: reference.consumer_path.clone(),
line: reference.line,
available_in_catalogs,
});
}
findings
}
fn catalog_has_entry(data: &PnpmCatalogData, catalog_name: &str, package_name: &str) -> bool {
data.catalogs
.iter()
.filter(|catalog| catalog.name == catalog_name)
.flat_map(|catalog| catalog.entries.iter())
.any(|entry| entry.package_name == package_name)
}
fn collect_available_in_catalogs(
data: &PnpmCatalogData,
package_name: &str,
excluded_catalog: &str,
) -> Vec<String> {
let mut catalogs: Vec<String> = data
.catalogs
.iter()
.filter(|catalog| catalog.name != excluded_catalog)
.filter(|catalog| {
catalog
.entries
.iter()
.any(|entry| entry.package_name == package_name)
})
.map(|catalog| catalog.name.clone())
.collect();
catalogs.sort();
catalogs.dedup();
catalogs
}
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>,
referenced_with_locations: Vec<ConsumerReference>,
hardcoded: Vec<(String, PathBuf)>,
}
#[derive(Debug, Clone)]
struct ConsumerReference {
package_name: String,
catalog_name: String,
consumer_path: PathBuf,
line: u32,
}
#[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 {
collect_catalog_consumers_from_package(pkg_path, root, &mut consumers);
}
consumers
}
fn collect_catalog_consumers_from_package(
pkg_path: &Path,
root: &Path,
consumers: &mut CatalogConsumers,
) {
let Ok(raw_source) = std::fs::read_to_string(pkg_path) else {
return;
};
let Ok(pkg) = serde_json::from_str::<PackageJson>(&raw_source) else {
return;
};
let relative_path = pkg_path
.strip_prefix(root)
.map_or_else(|_| pkg_path.to_path_buf(), Path::to_path_buf);
let line_map = scan_dep_lines(&raw_source);
for (section, deps) in [
(DepSection::Dependencies, pkg.dependencies.as_ref()),
(DepSection::DevDependencies, pkg.dev_dependencies.as_ref()),
(DepSection::PeerDependencies, pkg.peer_dependencies.as_ref()),
(
DepSection::OptionalDependencies,
pkg.optional_dependencies.as_ref(),
),
] {
let Some(deps) = deps else {
continue;
};
for (name, version) in deps {
collect_catalog_consumer_dependency(
consumers,
name,
version,
section,
&line_map,
pkg_path,
&relative_path,
);
}
}
}
fn collect_catalog_consumer_dependency(
consumers: &mut CatalogConsumers,
name: &str,
version: &str,
section: DepSection,
line_map: &DepLineMap,
absolute_path: &Path,
relative_path: &Path,
) {
if let Some(catalog) = parse_catalog_reference(version) {
consumers.references.insert(OwnedConsumerKey {
package_name: name.to_string(),
catalog_name: catalog.to_string(),
});
let line = line_map.line_for(section, name).unwrap_or(1);
consumers.referenced_with_locations.push(ConsumerReference {
package_name: name.to_string(),
catalog_name: catalog.to_string(),
consumer_path: absolute_path.to_path_buf(),
line,
});
} else if is_hardcoded_version(version) {
consumers
.hardcoded
.push((name.to_string(), relative_path.to_path_buf()));
}
}
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:"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum DepSection {
Dependencies,
DevDependencies,
PeerDependencies,
OptionalDependencies,
}
const DEP_SECTIONS: &[DepSection] = &[
DepSection::Dependencies,
DepSection::DevDependencies,
DepSection::PeerDependencies,
DepSection::OptionalDependencies,
];
impl DepSection {
const fn json_key(self) -> &'static str {
match self {
Self::Dependencies => "dependencies",
Self::DevDependencies => "devDependencies",
Self::PeerDependencies => "peerDependencies",
Self::OptionalDependencies => "optionalDependencies",
}
}
}
#[derive(Debug, Default)]
struct DepLineMap {
entries: Vec<((DepSection, String), u32)>,
}
impl DepLineMap {
fn line_for(&self, section: DepSection, package_name: &str) -> Option<u32> {
self.entries
.iter()
.find(|((sec, pkg), _)| *sec == section && pkg == package_name)
.map(|(_, line)| *line)
}
}
fn scan_dep_lines(source: &str) -> DepLineMap {
let mut scanner = DepLineScanner::default();
for (idx, raw_line) in source.lines().enumerate() {
scanner.scan_line(idx, raw_line);
}
scanner.finish()
}
#[derive(Default)]
struct DepLineScanner {
entries: Vec<((DepSection, String), u32)>,
current_section: Option<DepSection>,
section_depth_at_open: u32,
current_depth: u32,
}
impl DepLineScanner {
fn scan_line(&mut self, idx: usize, raw_line: &str) {
let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
let trimmed = raw_line.trim();
self.enter_section_if_opened(trimmed, raw_line);
let depth_before = self.current_depth;
let delta = brace_delta_outside_strings(raw_line);
self.record_dep_key_if_in_section(depth_before, trimmed, line_no);
self.current_depth = depth_before
.saturating_add(delta.opens)
.saturating_sub(delta.closes);
self.leave_section_if_closed();
}
fn enter_section_if_opened(&mut self, trimmed: &str, raw_line: &str) {
if self.current_section.is_some() {
return;
}
for section in DEP_SECTIONS {
let needle = format!("\"{}\"", section.json_key());
if trimmed.starts_with(&needle) && raw_line.contains('{') {
self.current_section = Some(*section);
self.section_depth_at_open = self.current_depth.saturating_add(1);
break;
}
}
}
fn record_dep_key_if_in_section(&mut self, depth_before: u32, trimmed: &str, line_no: u32) {
if let Some(section) = self.current_section
&& depth_before == self.section_depth_at_open
&& let Some(name) = parse_json_key(trimmed)
{
self.entries.push(((section, name), line_no));
}
}
fn leave_section_if_closed(&mut self) {
if self.current_section.is_some() && self.current_depth < self.section_depth_at_open {
self.current_section = None;
}
}
fn finish(self) -> DepLineMap {
DepLineMap {
entries: self.entries,
}
}
}
#[derive(Default)]
struct BraceDelta {
opens: u32,
closes: u32,
}
fn brace_delta_outside_strings(raw_line: &str) -> BraceDelta {
let mut delta = BraceDelta::default();
let mut in_string = false;
let mut escaped = false;
for ch in raw_line.chars() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
match ch {
'{' => delta.opens = delta.opens.saturating_add(1),
'}' => delta.closes = delta.closes.saturating_add(1),
_ => {}
}
}
delta
}
fn parse_json_key(trimmed: &str) -> Option<String> {
let rest = trimmed.strip_prefix('"')?;
let end = rest.find('"')?;
let key = &rest[..end];
let after = rest[end.saturating_add(1)..].trim_start();
if after.starts_with(':') {
Some(key.to_string())
} else {
None
}
}
#[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"));
}
#[test]
fn scan_dep_lines_captures_each_section() {
let source = r#"{
"name": "demo",
"dependencies": {
"react": "catalog:react17",
"lodash": "^4.0.0"
},
"devDependencies": {
"vitest": "catalog:"
}
}
"#;
let map = scan_dep_lines(source);
assert_eq!(map.line_for(DepSection::Dependencies, "react"), Some(4));
assert_eq!(map.line_for(DepSection::Dependencies, "lodash"), Some(5));
assert_eq!(map.line_for(DepSection::DevDependencies, "vitest"), Some(8));
assert_eq!(map.line_for(DepSection::Dependencies, "vitest"), None);
}
#[test]
fn scan_dep_lines_ignores_nested_object_keys() {
let source = r#"{
"peerDependencies": {
"react": "*"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}
"#;
let map = scan_dep_lines(source);
assert_eq!(map.line_for(DepSection::PeerDependencies, "react"), Some(3));
let peer_react_hits = map
.entries
.iter()
.filter(|((sec, name), _)| *sec == DepSection::PeerDependencies && name == "react")
.count();
assert_eq!(peer_react_hits, 1);
}
#[test]
fn collect_available_in_catalogs_excludes_the_unresolved_one() {
let data = parse_pnpm_catalog_data(
r"
catalogs:
react17:
react: ^17.0.2
react18:
react: ^18.2.0
",
);
let available = collect_available_in_catalogs(&data, "react", "react17");
assert_eq!(available, vec!["react18".to_string()]);
}
#[test]
fn catalog_has_entry_default_form() {
let data = parse_pnpm_catalog_data(
r"
catalog:
react: ^18.2.0
",
);
assert!(catalog_has_entry(&data, "default", "react"));
assert!(!catalog_has_entry(&data, "default", "vue"));
assert!(!catalog_has_entry(&data, "react17", "react"));
}
}