use skif_core::HashMap;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use serde_json::Value;
use skif_core::FileSystem;
use crate::deps::{Installed, bins_of, peers_of};
#[derive(Default)]
struct Entry {
bins: BTreeSet<String>,
peers: BTreeSet<String>,
}
type Entries = HashMap<String, Entry>;
#[derive(Default)]
pub(crate) struct Locked {
pub packages: HashMap<String, Arc<Installed>>,
pub bun_config_version: Option<u64>,
}
pub(crate) fn read(fs: &dyn FileSystem, root: &Path) -> Locked {
let bun_lock = fs
.read_to_string(&root.join("bun.lock"))
.ok()
.map(|text| parse_bun(&text));
let bun_config_version = bun_lock.as_ref().and_then(|lock| lock.get("configVersion")?.as_u64());
let entries = if let Ok(text) = fs.read_to_string(&root.join("pnpm-lock.yaml")) {
pnpm(&text)
} else if let Ok(text) = fs.read_to_string(&root.join("package-lock.json")) {
npm(&text)
} else if let Some(lock) = &bun_lock {
bun(lock)
} else if let Ok(text) = fs.read_to_string(&root.join("yarn.lock")) {
yarn(&text)
} else {
Entries::default()
};
let packages = entries
.into_iter()
.map(|(name, entry)| {
let installed = Installed {
bins: entry.bins.into_iter().collect(),
peers: entry.peers.into_iter().collect(),
version: None,
};
(name, Arc::new(installed))
})
.collect();
Locked {
packages,
bun_config_version,
}
}
fn unquote(text: &str) -> &str {
let text = text.trim();
for quote in ['\'', '"'] {
if let Some(inner) = text.strip_prefix(quote) {
return inner.split(quote).next().unwrap_or(inner);
}
}
text
}
fn yaml_key(line: &str) -> Option<&str> {
let line = line.trim();
if line.starts_with(['\'', '"']) {
return Some(unquote(line));
}
line.split_once(':').map(|(key, _)| key.trim())
}
fn package_of_key(key: &str) -> Option<&str> {
let key = unquote(key.trim().trim_end_matches(':')).trim_start_matches('/');
let key = key.split('(').next().unwrap_or(key);
let at = key.rfind('@').filter(|at| *at > 0)?;
Some(&key[..at])
}
fn pnpm(text: &str) -> Entries {
#[derive(PartialEq)]
enum Section {
Peers,
Other,
}
let mut entries = Entries::default();
let mut in_packages = false;
let mut current: Option<String> = None;
let mut section = Section::Other;
for line in text.lines() {
let body = line.trim();
if body.is_empty() || body.starts_with('#') {
continue;
}
let indent = line.len() - line.trim_start().len();
match indent {
0 => {
in_packages = body == "packages:";
current = None;
}
2 if in_packages => {
current = package_of_key(body).map(str::to_owned);
if let Some(name) = ¤t {
entries.entry(name.clone()).or_default();
}
section = Section::Other;
}
4 if in_packages => {
section = if body == "peerDependencies:" {
Section::Peers
} else {
Section::Other
};
if body == "hasBin: true"
&& let Some(name) = ¤t
{
entries
.entry(name.clone())
.or_default()
.bins
.insert(name.rsplit('/').next().unwrap_or(name).to_owned());
}
}
6 if in_packages && section == Section::Peers => {
if let (Some(name), Some(peer)) = (¤t, yaml_key(body)) {
entries.entry(name.clone()).or_default().peers.insert(peer.to_owned());
}
}
_ => {}
}
}
entries
}
fn npm(text: &str) -> Entries {
let mut entries = Entries::default();
let Ok(value) = serde_json::from_str::<Value>(text) else {
return entries;
};
let Some(packages) = value.get("packages").and_then(Value::as_object) else {
return entries;
};
for (key, package) in packages {
let Some((_, name)) = key.rsplit_once("node_modules/") else {
continue;
};
let entry = entries.entry(name.to_owned()).or_default();
entry.bins.extend(bins_of(package, name));
entry.peers.extend(peers_of(package));
}
entries
}
fn parse_bun(text: &str) -> Value {
skif_workspace::parse_bun_lock(text).unwrap_or(Value::Null)
}
fn bun(value: &Value) -> Entries {
let mut entries = Entries::default();
let Some(packages) = value.get("packages").and_then(Value::as_object) else {
return entries;
};
for package in packages.values() {
let Some(parts) = package.as_array() else {
continue;
};
let Some(name) = parts.first().and_then(Value::as_str).and_then(package_of_key) else {
continue;
};
let entry = entries.entry(name.to_owned()).or_default();
if let Some(meta) = parts.iter().find(|part| part.is_object()) {
entry.bins.extend(bins_of(meta, name));
entry.peers.extend(peers_of(meta));
}
}
entries
}
pub(crate) fn is_yarn_berry(text: &str) -> bool {
text.lines().any(|line| line.trim_end() == "__metadata:")
}
fn yarn(text: &str) -> Entries {
#[derive(PartialEq)]
enum Section {
Peers,
Bins,
Other,
}
let mut entries = Entries::default();
if !is_yarn_berry(text) {
return entries;
}
let mut current: Option<String> = None;
let mut section = Section::Other;
for line in text.lines() {
let body = line.trim();
if body.is_empty() || body.starts_with('#') {
continue;
}
let indent = line.len() - line.trim_start().len();
match indent {
0 => {
section = Section::Other;
current = body
.trim_end_matches(':')
.split(',')
.next()
.and_then(package_of_key)
.map(str::to_owned);
if let Some(name) = ¤t {
entries.entry(name.clone()).or_default();
}
}
2 => {
section = match body {
"peerDependencies:" => Section::Peers,
"bin:" => Section::Bins,
_ => Section::Other,
};
}
4 => {
if let (Some(name), Some(key)) = (¤t, yaml_key(body)) {
let entry = entries.entry(name.clone()).or_default();
match section {
Section::Peers => {
entry.peers.insert(key.to_owned());
}
Section::Bins => {
entry.bins.insert(key.to_owned());
}
Section::Other => {}
}
}
}
_ => {}
}
}
entries
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn pnpm_lock_peers_and_bins() {
let entries = pnpm(
"lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n react:\n specifier: 18.0.0\n\n\
packages:\n\n '@testing-library/react@16.0.0(@testing-library/dom@10.0.0)(react@18.0.0)':\n \
resolution: {integrity: sha512-x}\n peerDependencies:\n '@testing-library/dom': ^10.0.0\n \
react: ^18.0.0\n peerDependenciesMeta:\n '@types/react':\n optional: true\n\n \
oxlint@1.0.0:\n resolution: {integrity: sha512-y}\n hasBin: true\n\nsnapshots:\n\n \
other@1.0.0:\n peerDependencies:\n nope: '1'\n",
);
let react = &entries["@testing-library/react"];
assert_eq!(
react.peers.iter().map(String::as_str).collect::<Vec<_>>(),
["@testing-library/dom", "react"]
);
assert_eq!(entries["oxlint"].bins.iter().next().map(String::as_str), Some("oxlint"));
assert!(!entries.contains_key("other"));
}
#[test]
fn npm_lock_peers_and_bins() {
let entries = npm(r#"{"packages":{"":{"name":"app"},
"node_modules/a/node_modules/@s/tool":{"bin":{"tool-cli":"cli.js"},"peerDependencies":{"p":"1"}},
"node_modules/solo":{"bin":"x.js"}}}"#);
assert!(entries["@s/tool"].bins.contains("tool-cli") && entries["@s/tool"].peers.contains("p"));
assert!(entries["solo"].bins.contains("solo"));
}
#[test]
fn bun_lock_peers_and_bins() {
let entries = bun(&parse_bun(
r#"{
"lockfileVersion": 1,
"workspaces": { "": { "name": "app", }, },
"packages": {
"@s/react": ["@s/react@16.0.0", "", { "peerDependencies": { "@s/dom": "^10", "react": "^18", }, }, "sha512-x"],
"oxlint": ["oxlint@1.0.0", "", { "bin": { "oxlint": "bin.js" } }, "sha512-y"],
"app": ["app@workspace:."],
},
}"#,
));
assert!(entries["@s/react"].peers.contains("@s/dom") && entries["@s/react"].peers.contains("react"));
assert!(entries["oxlint"].bins.contains("oxlint"));
}
#[test]
fn read_records_the_config_version_of_bun_lock() {
let lock = |config: &str| format!("{{\n \"lockfileVersion\": 1,{config}\n \"workspaces\": {{}},\n}}\n");
let cases = [
(lock("\n \"configVersion\": 1,"), Some(1)),
(lock("\n \"configVersion\": 0,"), Some(0)),
(lock(""), None),
(format!("\u{feff}{}", lock("\n \"configVersion\": 1,")), Some(1)),
("not json".to_owned(), None),
];
for (text, expected) in cases {
let fs = skif_core::MemoryFs::new().with_file("/r/bun.lock", &text);
assert_eq!(read(&fs, Path::new("/r")).bun_config_version, expected, "{text}");
}
let fs = skif_core::MemoryFs::new()
.with_file("/r/pnpm-lock.yaml", "lockfileVersion: '9.0'\n")
.with_file("/r/bun.lock", lock("\n \"configVersion\": 1,"));
assert_eq!(read(&fs, Path::new("/r")).bun_config_version, Some(1));
assert_eq!(
read(&skif_core::MemoryFs::new(), Path::new("/r")).bun_config_version,
None
);
}
#[test]
fn yarn_berry_lock_peers_and_bins() {
let entries = yarn(
"__metadata:\n version: 8\n\n\"@s/react@npm:^16.0.0, @s/react@npm:^16.1.0\":\n version: 16.0.0\n \
peerDependencies:\n \"@s/dom\": ^10.0.0\n react: ^18.0.0\n checksum: abc\n\n\
\"oxlint@npm:1.0.0\":\n version: 1.0.0\n bin:\n oxlint: bin.js\n languageName: node\n",
);
assert!(entries["@s/react"].peers.contains("@s/dom") && entries["@s/react"].peers.contains("react"));
assert!(entries["oxlint"].bins.contains("oxlint"));
}
#[test]
fn yarn_classic_lock_is_not_read() {
let entries = yarn(
"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n\n\n vitest@^1.0.0:\n version \"1.0.0\"\n resolved \"https://registry.yarnpkg.com/vitest/-/vitest-1.0.0.tgz\"\n integrity sha512-x\n dependencies:\n tinypool \"^0.8.0\"\n\n prettier@^3.0.0:\n version \"3.0.0\"\n resolved \"https://registry.yarnpkg.com/prettier/-/prettier-3.0.0.tgz\"\n",
);
assert!(
entries.is_empty(),
"classic lock read: {:?}",
entries.keys().collect::<Vec<_>>()
);
}
#[test]
fn read_skips_a_classic_yarn_lock_but_reads_berry() {
let classic = "# yarn lockfile v1\n\n\nvitest@^1.0.0:\n version \"1.0.0\"\n";
let berry =
"__metadata:\n version: 8\n\n\"vitest@npm:^1.0.0\":\n version: 1.0.0\n bin:\n vitest: vitest.mjs\n";
let fs = skif_core::MemoryFs::new().with_file("/r/yarn.lock", classic);
assert!(read(&fs, Path::new("/r")).packages.is_empty());
let fs = skif_core::MemoryFs::new().with_file("/r/yarn.lock", berry);
let entries = read(&fs, Path::new("/r")).packages;
assert_eq!(entries["vitest"].bins, ["vitest"]);
}
}