#[derive(Debug, Clone, Default)]
pub struct PnpmCatalogData {
pub catalogs: Vec<PnpmCatalog>,
}
#[derive(Debug, Clone)]
pub struct PnpmCatalog {
pub name: String,
pub entries: Vec<PnpmCatalogEntry>,
}
#[derive(Debug, Clone)]
pub struct PnpmCatalogEntry {
pub package_name: String,
pub line: u32,
}
#[must_use]
pub fn parse_pnpm_catalog_data(source: &str) -> PnpmCatalogData {
let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(source) {
Ok(v) => v,
Err(_) => return PnpmCatalogData::default(),
};
let Some(mapping) = value.as_mapping() else {
return PnpmCatalogData::default();
};
let line_index = build_line_index(source);
let mut catalogs = Vec::new();
if let Some(default_value) = mapping.get("catalog")
&& let Some(default_map) = default_value.as_mapping()
{
let entries = collect_entries(default_map, &line_index, "default");
if !entries.is_empty() {
catalogs.push(PnpmCatalog {
name: "default".to_string(),
entries,
});
}
}
if let Some(named_value) = mapping.get("catalogs")
&& let Some(named_map) = named_value.as_mapping()
{
for (name_value, catalog_value) in named_map {
let Some(name) = name_value.as_str() else {
continue;
};
let Some(catalog_map) = catalog_value.as_mapping() else {
continue;
};
let entries = collect_entries(catalog_map, &line_index, name);
if !entries.is_empty() {
catalogs.push(PnpmCatalog {
name: name.to_string(),
entries,
});
}
}
}
PnpmCatalogData { catalogs }
}
fn collect_entries(
mapping: &serde_yaml_ng::Mapping,
line_index: &CatalogLineIndex,
catalog_name: &str,
) -> Vec<PnpmCatalogEntry> {
mapping
.iter()
.filter_map(|(k, _)| {
let pkg = k.as_str()?;
let line = line_index.line_for(catalog_name, pkg)?;
Some(PnpmCatalogEntry {
package_name: pkg.to_string(),
line,
})
})
.collect()
}
struct CatalogLineIndex {
entries: Vec<((String, String), u32)>,
}
impl CatalogLineIndex {
fn line_for(&self, catalog_name: &str, package_name: &str) -> Option<u32> {
self.entries
.iter()
.find(|((cat, pkg), _)| cat == catalog_name && pkg == package_name)
.map(|(_, line)| *line)
}
}
fn build_line_index(source: &str) -> CatalogLineIndex {
let mut entries = Vec::new();
let mut section: Section = Section::None;
let mut named_catalog: Option<(String, usize)> = None;
for (idx, raw_line) in source.lines().enumerate() {
let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
let trimmed = strip_inline_comment(raw_line);
let trimmed_left = trimmed.trim_start();
let indent = trimmed.len() - trimmed_left.len();
if trimmed_left.is_empty() {
continue;
}
if indent == 0 {
section = if trimmed_left.starts_with("catalogs:") {
Section::NamedCatalogs
} else if trimmed_left.starts_with("catalog:") {
Section::DefaultCatalog
} else {
Section::None
};
named_catalog = None;
continue;
}
match section {
Section::None => {}
Section::DefaultCatalog => {
if let Some(name) = parse_key(trimmed_left) {
entries.push((("default".to_string(), name), line_no));
}
}
Section::NamedCatalogs => {
if let Some(name) = parse_key(trimmed_left) {
match &named_catalog {
Some((_, existing_indent)) if indent > *existing_indent => {
entries.push((
(
named_catalog
.as_ref()
.map_or_else(String::new, |(n, _)| n.clone()),
name,
),
line_no,
));
}
_ => {
named_catalog = Some((name, indent));
}
}
}
}
}
}
CatalogLineIndex { entries }
}
#[derive(Debug, Clone, Copy)]
enum Section {
None,
DefaultCatalog,
NamedCatalogs,
}
fn strip_inline_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut in_single = false;
let mut in_double = false;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'\'' if !in_double => in_single = !in_single,
b'"' if !in_single => in_double = !in_double,
b'#' if !in_single && !in_double => {
let head = &line[..i];
return head.trim_end();
}
_ => {}
}
}
line.trim_end()
}
fn parse_key(line: &str) -> Option<String> {
let bytes = line.as_bytes();
if bytes.is_empty() {
return None;
}
let first = bytes[0];
if first == b'-' || first == b'#' {
return None;
}
if first == b'"' || first == b'\'' {
let quote = first;
let mut i = 1;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == quote {
let key = &line[1..i];
let rest = &line[i + 1..];
let trimmed = rest.trim_start();
if trimmed.starts_with(':') {
return Some(unescape_key(key));
}
return None;
}
i += 1;
}
return None;
}
let colon_pos = bytes.iter().position(|&b| b == b':')?;
let key = line[..colon_pos].trim();
if key.is_empty() {
return None;
}
if key.contains(['{', '[', '&', '*', '!']) {
return None;
}
Some(key.to_string())
}
fn unescape_key(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(next) = chars.next()
{
match next {
'n' => out.push('\n'),
't' => out.push('\t'),
'"' => out.push('"'),
'\\' => out.push('\\'),
other => {
out.push('\\');
out.push(other);
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_catalog() {
let yaml = "packages:\n - 'packages/*'\n\ncatalog:\n react: ^18.2.0\n is-even: ^1.0.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 1);
let default = &data.catalogs[0];
assert_eq!(default.name, "default");
assert_eq!(default.entries.len(), 2);
assert_eq!(default.entries[0].package_name, "react");
assert_eq!(default.entries[0].line, 5);
assert_eq!(default.entries[1].package_name, "is-even");
assert_eq!(default.entries[1].line, 6);
}
#[test]
fn parses_named_catalogs() {
let yaml = "catalogs:\n react17:\n react: ^17.0.2\n react-dom: ^17.0.2\n ui:\n headlessui: ^2.0.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 2);
assert_eq!(data.catalogs[0].name, "react17");
assert_eq!(data.catalogs[0].entries.len(), 2);
assert_eq!(data.catalogs[0].entries[0].package_name, "react");
assert_eq!(data.catalogs[0].entries[0].line, 3);
assert_eq!(data.catalogs[1].name, "ui");
assert_eq!(data.catalogs[1].entries[0].package_name, "headlessui");
assert_eq!(data.catalogs[1].entries[0].line, 6);
}
#[test]
fn handles_default_and_named_together() {
let yaml = "catalog:\n react: ^18\n\ncatalogs:\n legacy:\n react: ^17\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 2);
assert_eq!(data.catalogs[0].name, "default");
assert_eq!(data.catalogs[0].entries[0].line, 2);
assert_eq!(data.catalogs[1].name, "legacy");
assert_eq!(data.catalogs[1].entries[0].line, 6);
}
#[test]
fn handles_quoted_keys() {
let yaml = "catalog:\n \"@scope/lib\": ^1.0.0\n 'my-pkg': ^2.0.0\n";
let data = parse_pnpm_catalog_data(yaml);
let default = &data.catalogs[0];
assert_eq!(default.entries[0].package_name, "@scope/lib");
assert_eq!(default.entries[0].line, 2);
assert_eq!(default.entries[1].package_name, "my-pkg");
assert_eq!(default.entries[1].line, 3);
}
#[test]
fn handles_inline_comments() {
let yaml = "catalog:\n react: ^18 # pin until #1234\n is-even: ^1.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs[0].entries.len(), 2);
assert_eq!(data.catalogs[0].entries[0].package_name, "react");
assert_eq!(data.catalogs[0].entries[1].package_name, "is-even");
assert_eq!(data.catalogs[0].entries[1].line, 3);
}
#[test]
fn handles_four_space_indentation() {
let yaml = "catalog:\n react: ^18.2.0\n vue: ^3.4.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs[0].entries.len(), 2);
assert_eq!(data.catalogs[0].entries[0].line, 2);
assert_eq!(data.catalogs[0].entries[1].line, 3);
}
#[test]
fn empty_catalog_returns_no_catalogs() {
let yaml = "catalog: {}\n";
let data = parse_pnpm_catalog_data(yaml);
assert!(data.catalogs.is_empty());
}
#[test]
fn no_catalog_keys_returns_no_catalogs() {
let yaml = "packages:\n - 'packages/*'\n";
let data = parse_pnpm_catalog_data(yaml);
assert!(data.catalogs.is_empty());
}
#[test]
fn malformed_yaml_returns_no_catalogs() {
let yaml = "{this is\nnot: valid: yaml: at: all";
let data = parse_pnpm_catalog_data(yaml);
assert!(data.catalogs.is_empty());
}
#[test]
fn empty_input_returns_no_catalogs() {
let data = parse_pnpm_catalog_data("");
assert!(data.catalogs.is_empty());
}
#[test]
fn handles_object_form_entries() {
let yaml = "catalog:\n react:\n specifier: ^18.2.0\n vue: ^3.4.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs[0].entries.len(), 2);
let names: Vec<_> = data.catalogs[0]
.entries
.iter()
.map(|e| e.package_name.as_str())
.collect();
assert!(names.contains(&"react"));
assert!(names.contains(&"vue"));
}
#[test]
fn skips_packages_section() {
let yaml = "packages:\n - 'apps/*'\n - 'libs/*'\ncatalog:\n react: ^18\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 1);
assert_eq!(data.catalogs[0].entries[0].line, 5);
}
#[test]
fn strip_inline_comment_preserves_quoted_hash() {
assert_eq!(strip_inline_comment("foo: \"a#b\" # tail"), "foo: \"a#b\"");
assert_eq!(strip_inline_comment("# top-level"), "");
assert_eq!(strip_inline_comment("plain: value"), "plain: value");
}
#[test]
fn parse_key_handles_simple_and_quoted() {
assert_eq!(parse_key("react: ^18"), Some("react".to_string()));
assert_eq!(
parse_key("\"@scope/lib\": ^1"),
Some("@scope/lib".to_string())
);
assert_eq!(parse_key("'pkg': ^2"), Some("pkg".to_string()));
assert_eq!(parse_key("- item"), None);
assert_eq!(parse_key(""), None);
}
}