use std::collections::BTreeMap;
use std::collections::HashMap;
use serde_json::Value;
use yaml_parser::SyntaxError;
use yaml_parser::ast::AstNode;
use yaml_parser::ast::BlockMap;
use yaml_parser::ast::BlockMapKey;
use yaml_parser::ast::BlockMapValue;
use yaml_parser::ast::Flow;
use yaml_parser::ast::FlowMap;
use yaml_parser::ast::Root;
#[derive(Debug, thiserror::Error)]
pub enum PnpmLockfileImportError {
#[error("Failed to parse pnpm-lock.yaml")]
Parse(#[source] SyntaxError),
#[error("pnpm-lock.yaml is empty or not a mapping")]
EmptyOrInvalid,
#[error(
"Unsupported pnpm-lock.yaml `lockfileVersion`: {0}. Supported versions are 6.x and 9.x."
)]
UnsupportedVersion(String),
}
pub fn pnpm_lock_to_deno_lock_v5(
yaml_text: &str,
) -> Result<String, PnpmLockfileImportError> {
let syntax =
yaml_parser::parse(yaml_text).map_err(PnpmLockfileImportError::Parse)?;
let root_map = Root::cast(syntax)
.and_then(|root| root.documents().next())
.and_then(|doc| doc.block())
.and_then(|block| block.block_map())
.map(MapNode::Block)
.ok_or(PnpmLockfileImportError::EmptyOrInvalid)?;
let version = root_map
.get("lockfileVersion")
.and_then(Node::into_string)
.ok_or_else(
|| PnpmLockfileImportError::UnsupportedVersion(String::new()),
)?;
let major = version
.split('.')
.next()
.and_then(|s| s.parse::<u32>().ok())
.ok_or_else(|| {
PnpmLockfileImportError::UnsupportedVersion(version.clone())
})?;
if !matches!(major, 6 | 9) {
return Err(PnpmLockfileImportError::UnsupportedVersion(version));
}
let mut integrity: HashMap<String, String> = HashMap::new();
if let Some(packages) = root_map.get("packages").and_then(Node::into_map) {
for (key, value) in packages.entries() {
let key = normalize_package_key(&key);
let base = strip_peer_suffix(&key).to_string();
if let Some(integ) = value
.into_map()
.and_then(|m| m.get("resolution"))
.and_then(Node::into_map)
.and_then(|m| m.get("integrity"))
.and_then(Node::into_string)
{
integrity.entry(base).or_insert(integ);
}
}
}
let mut npm: BTreeMap<String, Value> = BTreeMap::new();
for section in ["snapshots", "packages"] {
let Some(snaps) = root_map.get(section).and_then(Node::into_map) else {
continue;
};
for (raw_key, value) in snaps.entries() {
let normalized = normalize_package_key(&raw_key);
let base = strip_peer_suffix(&normalized).to_string();
if npm.contains_key(&base) {
continue;
}
let Some(integ) = integrity.get(&base) else {
continue;
};
let value_map = value.into_map();
let deps = collect_deps(
value_map
.as_ref()
.and_then(|m| m.get("dependencies"))
.and_then(Node::into_map),
);
let optional_deps = collect_deps(
value_map
.as_ref()
.and_then(|m| m.get("optionalDependencies"))
.and_then(Node::into_map),
);
let mut entry = serde_json::Map::new();
entry.insert("integrity".to_string(), Value::String(integ.clone()));
if !deps.is_empty() {
entry.insert(
"dependencies".to_string(),
Value::Array(deps.into_iter().map(Value::String).collect()),
);
}
if !optional_deps.is_empty() {
entry.insert(
"optionalDependencies".to_string(),
Value::Array(optional_deps.into_iter().map(Value::String).collect()),
);
}
npm.insert(base, Value::Object(entry));
}
}
for (base, integ) in &integrity {
npm.entry(base.clone()).or_insert_with(|| {
let mut entry = serde_json::Map::new();
entry.insert("integrity".to_string(), Value::String(integ.clone()));
Value::Object(entry)
});
}
let catalogs = collect_catalogs(&root_map);
let mut specifiers: BTreeMap<String, String> = BTreeMap::new();
let mut root_dep_keys: Vec<String> = Vec::new();
let mut member_dep_keys: BTreeMap<String, Vec<String>> = BTreeMap::new();
if let Some(importers) = root_map.get("importers").and_then(Node::into_map) {
for (path, importer) in importers.entries() {
let Some(importer) = importer.into_map() else {
continue;
};
let keys =
collect_importer_specifiers(&importer, &catalogs, &mut specifiers);
if path == "." {
root_dep_keys = keys;
} else if !keys.is_empty() {
member_dep_keys.insert(path, keys);
}
}
}
if major == 6 {
let specifiers_section =
root_map.get("specifiers").and_then(Node::into_map);
for section in ["dependencies", "devDependencies", "optionalDependencies"] {
let Some(deps) = root_map.get(section).and_then(Node::into_map) else {
continue;
};
for (name, ver_node) in deps.entries() {
let Some(ver) = ver_node.into_string() else {
continue;
};
let spec = specifiers_section
.as_ref()
.and_then(|s| s.get(&name))
.and_then(Node::into_string)
.unwrap_or_else(|| ver.clone());
if !is_supported_spec(&spec) {
continue;
}
let resolved = strip_peer_suffix(&ver).to_string();
let key = format!("npm:{}@{}", name, spec);
specifiers.entry(key.clone()).or_insert(resolved);
root_dep_keys.push(key);
}
}
root_dep_keys.sort();
root_dep_keys.dedup();
}
let mut output = serde_json::Map::new();
output.insert("version".to_string(), Value::String("5".to_string()));
if !specifiers.is_empty() {
output.insert(
"specifiers".to_string(),
Value::Object(
specifiers
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect(),
),
);
}
if !npm.is_empty() {
output.insert("npm".to_string(), Value::Object(npm.into_iter().collect()));
}
if let Some(workspace) = build_workspace(root_dep_keys, member_dep_keys) {
output.insert("workspace".to_string(), workspace);
}
Ok(
serde_json::to_string(&Value::Object(output))
.expect("serializing deno.lock v5"),
)
}
enum Node {
Scalar(String),
Map(MapNode),
Other,
}
impl Node {
fn into_string(self) -> Option<String> {
match self {
Node::Scalar(s) => Some(s),
_ => None,
}
}
fn into_map(self) -> Option<MapNode> {
match self {
Node::Map(m) => Some(m),
_ => None,
}
}
}
enum MapNode {
Block(BlockMap),
Flow(FlowMap),
}
impl MapNode {
fn entries(&self) -> Vec<(String, Node)> {
match self {
MapNode::Block(block_map) => block_map
.entries()
.filter_map(|entry| {
let key = entry.key().and_then(|k| block_key_text(&k))?;
let value = entry
.value()
.map(|v| block_value_to_node(&v))
.unwrap_or(Node::Other);
Some((key, value))
})
.collect(),
MapNode::Flow(flow_map) => {
let Some(entries) = flow_map.entries() else {
return Vec::new();
};
entries
.entries()
.filter_map(|entry| {
let key = entry
.key()
.and_then(|k| k.flow())
.and_then(|f| flow_text(&f))?;
let value = entry
.value()
.and_then(|v| v.flow())
.map(|f| flow_to_node(&f))
.unwrap_or(Node::Other);
Some((key, value))
})
.collect()
}
}
}
fn get(&self, key: &str) -> Option<Node> {
self
.entries()
.into_iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
}
}
fn block_key_text(key: &BlockMapKey) -> Option<String> {
key.flow().and_then(|f| flow_text(&f))
}
fn block_value_to_node(value: &BlockMapValue) -> Node {
if let Some(block_map) = value.block().and_then(|b| b.block_map()) {
return Node::Map(MapNode::Block(block_map));
}
if let Some(flow) = value.flow() {
return flow_to_node(&flow);
}
Node::Other
}
fn flow_to_node(flow: &Flow) -> Node {
if let Some(text) = flow_text(flow) {
return Node::Scalar(text);
}
if let Some(flow_map) = flow.flow_map() {
return Node::Map(MapNode::Flow(flow_map));
}
Node::Other
}
fn flow_text(flow: &Flow) -> Option<String> {
if let Some(token) = flow.plain_scalar() {
return Some(token.text().trim().to_string());
}
if let Some(token) = flow.single_quoted_scalar() {
return Some(unquote_single(token.text()));
}
if let Some(token) = flow.double_qouted_scalar() {
return Some(unquote_double(token.text()));
}
None
}
fn unquote_single(raw: &str) -> String {
let inner = raw
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.unwrap_or(raw);
inner.replace("''", "'")
}
fn unquote_double(raw: &str) -> String {
let inner = raw
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(raw);
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('0') => out.push('\0'),
Some(other) => out.push(other),
None => {}
}
}
out
}
fn collect_catalogs(
root_map: &MapNode,
) -> HashMap<String, HashMap<String, String>> {
let mut catalogs: HashMap<String, HashMap<String, String>> = HashMap::new();
let Some(block) = root_map.get("catalogs").and_then(Node::into_map) else {
return catalogs;
};
for (catalog_name, entries) in block.entries() {
let Some(entries) = entries.into_map() else {
continue;
};
let mut map = HashMap::new();
for (dep_name, info) in entries.entries() {
if let Some(spec) = info
.into_map()
.and_then(|m| m.get("specifier"))
.and_then(Node::into_string)
{
map.insert(dep_name, spec);
}
}
catalogs.insert(catalog_name, map);
}
catalogs
}
fn collect_importer_specifiers(
importer: &MapNode,
catalogs: &HashMap<String, HashMap<String, String>>,
specifiers: &mut BTreeMap<String, String>,
) -> Vec<String> {
let mut keys = Vec::new();
for section in ["dependencies", "devDependencies", "optionalDependencies"] {
let Some(deps) = importer.get(section).and_then(Node::into_map) else {
continue;
};
for (name, info) in deps.entries() {
let Some(info) = info.into_map() else {
continue;
};
let Some(spec) = info.get("specifier").and_then(Node::into_string) else {
continue;
};
let Some(ver) = info.get("version").and_then(Node::into_string) else {
continue;
};
let resolved_spec = if let Some(catalog) = spec.strip_prefix("catalog:") {
let catalog_name = if catalog.is_empty() {
"default"
} else {
catalog
};
match catalogs.get(catalog_name).and_then(|m| m.get(&name)) {
Some(resolved) if is_supported_spec(resolved) => resolved.clone(),
_ => continue,
}
} else if is_supported_spec(&spec) {
spec.clone()
} else {
continue;
};
let resolved_ver = strip_peer_suffix(&ver).to_string();
let key = format!("npm:{}@{}", name, resolved_spec);
specifiers.entry(key.clone()).or_insert(resolved_ver);
keys.push(key);
}
}
keys.sort();
keys.dedup();
keys
}
fn build_workspace(
root_dep_keys: Vec<String>,
member_dep_keys: BTreeMap<String, Vec<String>>,
) -> Option<Value> {
fn package_json_deps(keys: Vec<String>) -> Value {
let mut package_json = serde_json::Map::new();
package_json.insert(
"dependencies".to_string(),
Value::Array(keys.into_iter().map(Value::String).collect()),
);
let mut obj = serde_json::Map::new();
obj.insert("packageJson".to_string(), Value::Object(package_json));
Value::Object(obj)
}
let mut workspace = serde_json::Map::new();
if !root_dep_keys.is_empty() {
if let Value::Object(root) = package_json_deps(root_dep_keys) {
workspace.extend(root);
}
}
if !member_dep_keys.is_empty() {
let members = member_dep_keys
.into_iter()
.map(|(path, keys)| (path, package_json_deps(keys)))
.collect();
workspace.insert("members".to_string(), Value::Object(members));
}
if workspace.is_empty() {
None
} else {
Some(Value::Object(workspace))
}
}
fn collect_deps(node: Option<MapNode>) -> Vec<String> {
let Some(map) = node else {
return Vec::new();
};
let mut out: Vec<String> = map
.entries()
.into_iter()
.filter_map(|(name, value)| {
let ver = value.into_string()?;
let ver = strip_peer_suffix(&ver);
Some(format!("{}@{}", name, ver))
})
.collect();
out.sort();
out.dedup();
out
}
fn normalize_package_key(key: &str) -> String {
let stripped = key.strip_prefix('/').unwrap_or(key);
if !stripped.contains('@') || stripped.starts_with('@') {
if let Some(idx) = stripped.rfind('/') {
let (name, ver) = stripped.split_at(idx);
let ver = &ver[1..];
if ver.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return format!("{}@{}", name, ver);
}
}
}
stripped.to_string()
}
fn strip_peer_suffix(key: &str) -> &str {
match key.find('(') {
Some(idx) => &key[..idx],
None => key,
}
}
fn is_supported_spec(req: &str) -> bool {
!req.starts_with("file:")
&& !req.starts_with("link:")
&& !req.starts_with("workspace:")
&& !req.starts_with("git+")
&& !req.starts_with("git:")
&& !req.starts_with("github:")
&& !req.starts_with("http:")
&& !req.starts_with("https:")
&& !req.starts_with("npm:")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translates_simple_v9() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
lodash:
specifier: ^4.17.21
version: 4.17.21
packages:
lodash@4.17.21:
resolution: {integrity: sha512-AAA}
snapshots:
lodash@4.17.21: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["version"], "5");
assert_eq!(v["specifiers"]["npm:lodash@^4.17.21"], "4.17.21");
assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-AAA");
}
#[test]
fn translates_v9_with_nested_deps() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
chalk:
specifier: ^4.0.0
version: 4.1.2
packages:
chalk@4.1.2:
resolution: {integrity: sha512-CHALK}
ansi-styles@4.3.0:
resolution: {integrity: sha512-ANSI}
snapshots:
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
ansi-styles@4.3.0: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["npm"]["chalk@4.1.2"]["integrity"], "sha512-CHALK");
let chalk_deps =
v["npm"]["chalk@4.1.2"]["dependencies"].as_array().unwrap();
assert_eq!(chalk_deps[0], "ansi-styles@4.3.0");
assert_eq!(v["npm"]["ansi-styles@4.3.0"]["integrity"], "sha512-ANSI");
}
#[test]
fn strips_peer_suffix() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
some-plugin:
specifier: ^1.0.0
version: 1.0.0(react@18.3.1)
packages:
some-plugin@1.0.0:
resolution: {integrity: sha512-PLUGIN}
react@18.3.1:
resolution: {integrity: sha512-REACT}
snapshots:
some-plugin@1.0.0(react@18.3.1):
dependencies:
react: 18.3.1
react@18.3.1: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:some-plugin@^1.0.0"], "1.0.0");
let plugin_deps = v["npm"]["some-plugin@1.0.0"]["dependencies"]
.as_array()
.unwrap();
assert_eq!(plugin_deps[0], "react@18.3.1");
}
#[test]
fn scoped_packages_v9() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
'@scope/pkg':
specifier: ^1.0.0
version: 1.2.3
packages:
'@scope/pkg@1.2.3':
resolution: {integrity: sha512-XXX}
snapshots:
'@scope/pkg@1.2.3': {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:@scope/pkg@^1.0.0"], "1.2.3");
assert!(
v["npm"]
.as_object()
.unwrap()
.contains_key("@scope/pkg@1.2.3")
);
}
#[test]
fn translates_v6() {
let input = r#"
lockfileVersion: '6.0'
specifiers:
lodash: ^4.17.21
dependencies:
lodash: 4.17.21
packages:
/lodash@4.17.21:
resolution: {integrity: sha512-LODASH}
dev: false
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:lodash@^4.17.21"], "4.17.21");
assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-LODASH");
}
#[test]
fn skips_aliased_specifier() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
my-lodash:
specifier: npm:lodash@^4.17.21
version: lodash@4.17.21
packages:
lodash@4.17.21:
resolution: {integrity: sha512-AAA}
snapshots:
lodash@4.17.21: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("specifiers").is_none());
assert_eq!(v["npm"]["lodash@4.17.21"]["integrity"], "sha512-AAA");
}
#[test]
fn captures_optional_dependencies() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
pkg:
specifier: ^1.0.0
version: 1.0.0
packages:
pkg@1.0.0:
resolution: {integrity: sha512-PKG}
fsevents@2.3.3:
resolution: {integrity: sha512-FS}
snapshots:
pkg@1.0.0:
optionalDependencies:
fsevents: 2.3.3
fsevents@2.3.3: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
let opt = v["npm"]["pkg@1.0.0"]["optionalDependencies"]
.as_array()
.unwrap();
assert_eq!(opt[0], "fsevents@2.3.3");
}
#[test]
fn seeds_workspace_members() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
is-number:
specifier: 7.0.0
version: 7.0.0
packages/app:
dependencies:
is-odd:
specifier: 3.0.1
version: 3.0.1
packages:
is-number@7.0.0:
resolution: {integrity: sha512-NUM}
is-odd@3.0.1:
resolution: {integrity: sha512-ODD}
snapshots:
is-number@7.0.0: {}
is-odd@3.0.1: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:is-number@7.0.0"], "7.0.0");
assert_eq!(v["specifiers"]["npm:is-odd@3.0.1"], "3.0.1");
assert_eq!(
v["workspace"]["packageJson"]["dependencies"][0],
"npm:is-number@7.0.0"
);
assert_eq!(
v["workspace"]["members"]["packages/app"]["packageJson"]["dependencies"]
[0],
"npm:is-odd@3.0.1"
);
}
#[test]
fn resolves_default_catalog() {
let input = r#"
lockfileVersion: '9.0'
catalogs:
default:
is-odd:
specifier: 3.0.1
version: 3.0.1
importers:
.:
dependencies:
is-odd:
specifier: 'catalog:'
version: 3.0.1
packages:
is-odd@3.0.1:
resolution: {integrity: sha512-ODD}
snapshots:
is-odd@3.0.1: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:is-odd@3.0.1"], "3.0.1");
assert_eq!(
v["workspace"]["packageJson"]["dependencies"][0],
"npm:is-odd@3.0.1"
);
assert_eq!(v["npm"]["is-odd@3.0.1"]["integrity"], "sha512-ODD");
}
#[test]
fn resolves_named_catalog() {
let input = r#"
lockfileVersion: '9.0'
catalogs:
react18:
react:
specifier: ^18.0.0
version: 18.3.1
importers:
.:
dependencies:
react:
specifier: 'catalog:react18'
version: 18.3.1
packages:
react@18.3.1:
resolution: {integrity: sha512-REACT}
snapshots:
react@18.3.1: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:react@^18.0.0"], "18.3.1");
}
#[test]
fn member_via_catalog() {
let input = r#"
lockfileVersion: '9.0'
catalogs:
default:
is-odd:
specifier: 3.0.1
version: 3.0.1
importers:
.:
dependencies:
is-number:
specifier: 7.0.0
version: 7.0.0
packages/app:
dependencies:
is-odd:
specifier: 'catalog:'
version: 3.0.1
packages:
is-number@7.0.0:
resolution: {integrity: sha512-NUM}
is-odd@3.0.1:
resolution: {integrity: sha512-ODD}
snapshots:
is-number@7.0.0: {}
is-odd@3.0.1: {}
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:is-odd@3.0.1"], "3.0.1");
assert_eq!(
v["workspace"]["members"]["packages/app"]["packageJson"]["dependencies"]
[0],
"npm:is-odd@3.0.1"
);
}
#[test]
fn skips_unknown_catalog_entry() {
let input = r#"
lockfileVersion: '9.0'
importers:
.:
dependencies:
is-odd:
specifier: 'catalog:'
version: 3.0.1
"#;
let out = pnpm_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("specifiers").is_none());
assert!(v.get("workspace").is_none());
}
#[test]
fn rejects_unsupported_version() {
let input = r#"lockfileVersion: '4.0'
packages: {}
"#;
let err = pnpm_lock_to_deno_lock_v5(input).unwrap_err();
assert!(matches!(
err,
PnpmLockfileImportError::UnsupportedVersion(_)
));
}
}