use crate::{
DepType, DirectDep, Error, GitSource, LocalSource, LockedPackage, LockfileGraph,
RemoteTarballSource,
};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub fn parse(path: &Path, manifest: &aube_manifest::PackageJson) -> Result<LockfileGraph, Error> {
let content = crate::read_lockfile(path)?;
if is_berry(&content) {
parse_berry_str(path, &content, manifest)
} else {
parse_classic_str(path, &content, manifest)
}
}
pub fn is_berry(content: &str) -> bool {
content
.lines()
.any(|l| l.trim_start().starts_with("__metadata:"))
}
pub fn is_berry_path(path: &Path) -> bool {
use std::io::Read;
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut buf = [0u8; 4096];
let n = f.read(&mut buf).unwrap_or(0);
let needle = b"__metadata:";
buf[..n]
.windows(needle.len())
.enumerate()
.any(|(i, w)| w == needle && (i == 0 || buf[i - 1] == b'\n'))
}
fn parse_classic_str(
path: &Path,
content: &str,
manifest: &aube_manifest::PackageJson,
) -> Result<LockfileGraph, Error> {
let blocks = tokenize_blocks(content).map_err(|e| Error::parse(path, e))?;
let mut spec_to_dep_path: BTreeMap<String, String> = BTreeMap::new();
let mut packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
for block in &blocks {
let version = block
.fields
.get("version")
.ok_or_else(|| {
Error::parse(
path,
format!("yarn.lock block {:?} has no version", block.specs),
)
})?
.clone();
let name = parse_spec_name(&block.specs[0]).ok_or_else(|| {
Error::parse(
path,
format!(
"could not parse package name from yarn.lock spec '{}'",
block.specs[0]
),
)
})?;
let dep_path = format!("{name}@{version}");
for spec in &block.specs {
spec_to_dep_path.insert(spec.clone(), dep_path.clone());
}
if !packages.contains_key(&dep_path) {
let declared: BTreeMap<String, String> = block
.dependencies
.iter()
.map(|(n, r)| (n.clone(), r.clone()))
.collect();
packages.insert(
dep_path.clone(),
LockedPackage {
name: name.clone(),
version: version.clone(),
integrity: block.fields.get("integrity").cloned(),
dependencies: block
.dependencies
.iter()
.map(|(n, r)| (n.clone(), format!("{n}@{r}")))
.collect(),
dep_path,
declared_dependencies: declared,
..Default::default()
},
);
}
}
let mut resolved: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (dep_path, pkg) in &packages {
let mut deps: BTreeMap<String, String> = BTreeMap::new();
for (name, raw_spec) in &pkg.dependencies {
if let Some(resolved_path) = spec_to_dep_path.get(raw_spec) {
deps.insert(name.clone(), resolved_path.clone());
}
}
resolved.insert(dep_path.clone(), deps);
}
for (dep_path, deps) in resolved {
if let Some(pkg) = packages.get_mut(&dep_path) {
pkg.dependencies = deps;
}
}
let mut direct: Vec<DirectDep> = Vec::new();
let push_direct = |name: &str, range: &str, dep_type: DepType, direct: &mut Vec<DirectDep>| {
let spec = format!("{name}@{range}");
if let Some(dep_path) = spec_to_dep_path.get(&spec) {
direct.push(DirectDep {
name: name.to_string(),
dep_path: dep_path.clone(),
dep_type,
specifier: None,
});
}
};
for (name, range) in &manifest.dependencies {
push_direct(name, range, DepType::Production, &mut direct);
}
for (name, range) in &manifest.dev_dependencies {
push_direct(name, range, DepType::Dev, &mut direct);
}
for (name, range) in &manifest.optional_dependencies {
push_direct(name, range, DepType::Optional, &mut direct);
}
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), direct);
Ok(LockfileGraph {
importers,
packages,
..Default::default()
})
}
#[derive(Debug)]
struct Block {
specs: Vec<String>,
fields: BTreeMap<String, String>,
dependencies: BTreeMap<String, String>,
}
fn tokenize_blocks(content: &str) -> Result<Vec<Block>, String> {
let mut blocks: Vec<Block> = Vec::new();
let mut current: Option<Block> = None;
let mut in_deps = false;
for (lineno, raw_line) in content.lines().enumerate() {
let line_num = lineno + 1;
let line = raw_line.trim_end();
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let indent = line.len() - line.trim_start().len();
if indent == 0 {
if let Some(b) = current.take() {
blocks.push(b);
}
in_deps = false;
let header = line.trim_end_matches(':').trim();
if !line.ends_with(':') {
return Err(format!(
"line {line_num}: expected block header ending in ':', got '{line}'"
));
}
let specs = parse_header_specs(header).map_err(|e| format!("line {line_num}: {e}"))?;
current = Some(Block {
specs,
fields: BTreeMap::new(),
dependencies: BTreeMap::new(),
});
continue;
}
let block = current.as_mut().ok_or_else(|| {
format!("line {line_num}: unexpected indented content before any block header")
})?;
if indent == 2 {
in_deps = false;
let body = line.trim_start();
if body.ends_with(':') {
let section = body.trim_end_matches(':').trim();
if section == "dependencies"
|| section == "optionalDependencies"
|| section == "peerDependencies"
{
in_deps = section == "dependencies";
continue;
}
continue;
}
let (key, value) = split_key_value(body)
.ok_or_else(|| format!("line {line_num}: could not parse '{body}'"))?;
block.fields.insert(key, value);
} else if indent >= 4 && in_deps {
let body = line.trim_start();
let (name, range) = split_key_value(body)
.ok_or_else(|| format!("line {line_num}: could not parse dep '{body}'"))?;
block.dependencies.insert(name, range);
}
}
if let Some(b) = current.take() {
blocks.push(b);
}
Ok(blocks)
}
fn parse_header_specs(header: &str) -> Result<Vec<String>, String> {
let mut specs = Vec::new();
for raw in header.split(',') {
let s = raw.trim();
let unquoted = if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
{
&s[1..s.len() - 1]
} else {
s
};
if unquoted.is_empty() {
return Err(format!("empty spec in header '{header}'"));
}
specs.push(unquoted.to_string());
}
if specs.is_empty() {
return Err(format!("no specs parsed from header '{header}'"));
}
Ok(specs)
}
fn split_key_value(line: &str) -> Option<(String, String)> {
let (key, rest) = line.split_once(char::is_whitespace)?;
let value = rest.trim();
let unquoted = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2)
|| (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2)
{
&value[1..value.len() - 1]
} else {
value
};
Some((key.to_string(), unquoted.to_string()))
}
fn parse_spec_name(spec: &str) -> Option<String> {
if let Some(rest) = spec.strip_prefix('@') {
let slash = rest.find('/')?;
let after_slash = &rest[slash + 1..];
let at = after_slash.find('@')?;
Some(format!("@{}", &rest[..slash + 1 + at]))
} else {
let at = spec.find('@')?;
Some(spec[..at].to_string())
}
}
pub fn write_classic(
path: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<(), Error> {
let canonical = crate::build_canonical_map(graph);
let mut extra_specs: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
let root_importer_specs = manifest
.dependencies
.iter()
.chain(manifest.dev_dependencies.iter())
.chain(manifest.optional_dependencies.iter())
.chain(manifest.peer_dependencies.iter());
for dep in graph.importers.get(".").into_iter().flatten() {
let canonical_key = crate::npm::canonical_key_from_dep_path(&dep.dep_path);
if !canonical.contains_key(&canonical_key) {
continue;
}
let range = root_importer_specs
.clone()
.find(|(n, _)| n.as_str() == dep.name.as_str())
.map(|(_, r)| r.clone());
if let Some(range) = range {
let spec = format!("{}@{range}", dep.name);
if spec != canonical_key {
extra_specs.entry(canonical_key).or_default().insert(spec);
}
}
}
for pkg in canonical.values() {
for (dep_name, range) in &pkg.declared_dependencies {
let Some(resolved_value) = pkg.dependencies.get(dep_name) else {
continue;
};
let target = crate::npm::child_canonical_key(dep_name, resolved_value);
if !canonical.contains_key(&target) {
continue;
}
let spec = format!("{dep_name}@{range}");
if spec != target {
extra_specs.entry(target).or_default().insert(spec);
}
}
}
let mut out = String::with_capacity(canonical.len().saturating_mul(256).max(4096));
out.push_str("# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n");
out.push_str("# yarn lockfile v1\n\n\n");
for (canonical_key, pkg) in &canonical {
out.push('"');
out.push_str(canonical_key);
out.push('"');
if let Some(extras) = extra_specs.get(canonical_key) {
for spec in extras {
out.push_str(", \"");
out.push_str(spec);
out.push('"');
}
}
out.push_str(":\n");
out.push_str(" version \"");
out.push_str(&pkg.version);
out.push_str("\"\n");
if let Some(integ) = &pkg.integrity {
out.push_str(" integrity ");
out.push_str(integ);
out.push('\n');
}
let nonempty_deps: BTreeMap<&str, String> = pkg
.dependencies
.iter()
.filter_map(|(n, v)| {
let key = crate::npm::child_canonical_key(n, v);
if !canonical.contains_key(&key) {
return None;
}
let rendered = pkg
.declared_dependencies
.get(n)
.cloned()
.unwrap_or_else(|| crate::npm::dep_value_as_version(n, v).to_string());
Some((n.as_str(), rendered))
})
.collect();
if !nonempty_deps.is_empty() {
out.push_str(" dependencies:\n");
for (dep_name, dep_version) in &nonempty_deps {
out.push_str(" ");
out.push_str(dep_name);
out.push_str(" \"");
out.push_str(dep_version);
out.push_str("\"\n");
}
}
out.push('\n');
}
crate::atomic_write_lockfile(path, out.as_bytes())?;
Ok(())
}
fn parse_berry_str(
path: &Path,
content: &str,
manifest: &aube_manifest::PackageJson,
) -> Result<LockfileGraph, Error> {
let doc: yaml_serde::Value = yaml_serde::from_str(content)
.map_err(|e| Error::parse_yaml_err(path, content.to_string(), &e))?;
let map = doc
.as_mapping()
.ok_or_else(|| Error::parse(path, "yarn berry lockfile root must be a mapping"))?;
let meta_version = map
.get("__metadata")
.and_then(|m| m.as_mapping())
.and_then(|m| m.get("version"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
if meta_version < 3 {
return Err(Error::parse(
path,
format!(
"yarn berry lockfile has unexpected __metadata.version: {meta_version} (expected >= 3)"
),
));
}
let mut spec_to_dep_path: BTreeMap<String, String> = BTreeMap::new();
let mut packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
for (key, value) in map {
let Some(key_str) = key.as_str() else {
continue;
};
if key_str.starts_with("__") {
continue;
}
let block = value.as_mapping().ok_or_else(|| {
Error::parse(
path,
format!("yarn berry block '{key_str}' is not a mapping"),
)
})?;
let specs = split_berry_header(key_str);
if specs.is_empty() {
continue;
}
let version = block
.get("version")
.and_then(yaml_scalar_as_string)
.ok_or_else(|| {
Error::parse(path, format!("yarn berry block '{key_str}' has no version"))
})?;
let resolution = block
.get("resolution")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::parse(
path,
format!("yarn berry block '{key_str}' has no resolution"),
)
})?;
let (res_name, res_protocol, res_body) = parse_berry_spec(resolution).ok_or_else(|| {
Error::parse(
path,
format!("yarn berry block '{key_str}' has malformed resolution '{resolution}'"),
)
})?;
let local_source = match res_protocol {
"npm" => None,
"workspace" => {
for spec in &specs {
spec_to_dep_path.insert(spec.clone(), format!("{res_name}@{version}"));
}
continue;
}
"patch" | "portal" | "exec" => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_YARN_BERRY_UNSUPPORTED,
"yarn berry '{}' protocol in block '{}' is not supported — entry skipped",
res_protocol,
key_str,
);
continue;
}
"file" => Some(file_protocol_source(res_body)),
"link" => Some(LocalSource::Link(PathBuf::from(strip_hash_fragment(
res_body,
)))),
"http" | "https" => {
let url = format!("{res_protocol}:{res_body}");
classify_remote(&url, block)
}
p if p == "git" || p == "ssh" || p.starts_with("git+") || p.starts_with("ssh+") => {
let url = format!("{res_protocol}:{res_body}");
Some(LocalSource::Git(GitSource {
url: strip_commit_hash(&url),
committish: None,
resolved: extract_commit_hash(&url).unwrap_or_default(),
subpath: None,
}))
}
_ => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_YARN_BERRY_UNSUPPORTED,
"yarn berry unrecognized protocol '{}' in block '{}' — entry skipped",
res_protocol,
key_str,
);
continue;
}
};
let dep_path = match &local_source {
Some(src) => src.dep_path(res_name),
None => format!("{res_name}@{version}"),
};
for spec in &specs {
spec_to_dep_path.insert(spec.clone(), dep_path.clone());
}
let raw_deps = collect_dep_map(block, "dependencies");
let peer_deps = collect_dep_map(block, "peerDependencies");
let peer_deps_meta = collect_peer_meta(block);
let optional_deps = collect_dep_map(block, "optionalDependencies");
let mut declared: BTreeMap<String, String> = BTreeMap::new();
for (n, v) in raw_deps.iter().chain(optional_deps.iter()) {
declared.insert(n.clone(), v.clone());
}
let raw_deps_specs: BTreeMap<String, String> = raw_deps
.into_iter()
.map(|(n, v)| (n.clone(), format!("{n}@{v}")))
.collect();
let optional_deps_specs: BTreeMap<String, String> = optional_deps
.into_iter()
.map(|(n, v)| (n.clone(), format!("{n}@{v}")))
.collect();
let checksum = block
.get("checksum")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if !packages.contains_key(&dep_path) {
packages.insert(
dep_path.clone(),
LockedPackage {
name: res_name.to_string(),
version: version.clone(),
integrity: None,
yarn_checksum: checksum,
dependencies: raw_deps_specs,
optional_dependencies: optional_deps_specs,
peer_dependencies: peer_deps,
peer_dependencies_meta: peer_deps_meta,
dep_path: dep_path.clone(),
local_source,
declared_dependencies: declared,
..Default::default()
},
);
}
}
let mut resolved_deps: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
let mut resolved_opts: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (dep_path, pkg) in &packages {
let resolve = |raw: &BTreeMap<String, String>| {
let mut out = BTreeMap::new();
for (name, raw_spec) in raw {
if let Some(target) = spec_to_dep_path.get(raw_spec) {
out.insert(name.clone(), target.clone());
}
}
out
};
resolved_deps.insert(dep_path.clone(), resolve(&pkg.dependencies));
resolved_opts.insert(dep_path.clone(), resolve(&pkg.optional_dependencies));
}
for (dep_path, deps) in resolved_deps {
if let Some(pkg) = packages.get_mut(&dep_path) {
pkg.dependencies = deps;
}
}
for (dep_path, deps) in resolved_opts {
if let Some(pkg) = packages.get_mut(&dep_path) {
pkg.optional_dependencies = deps;
}
}
let mut direct: Vec<DirectDep> = Vec::new();
let push_direct = |name: &str, range: &str, dep_type: DepType, direct: &mut Vec<DirectDep>| {
let candidates = berry_spec_candidates(name, range);
for candidate in candidates {
if let Some(dep_path) = spec_to_dep_path.get(&candidate) {
direct.push(DirectDep {
name: name.to_string(),
dep_path: dep_path.clone(),
dep_type,
specifier: None,
});
return;
}
}
};
for (name, range) in &manifest.dependencies {
push_direct(name, range, DepType::Production, &mut direct);
}
for (name, range) in &manifest.dev_dependencies {
push_direct(name, range, DepType::Dev, &mut direct);
}
for (name, range) in &manifest.optional_dependencies {
push_direct(name, range, DepType::Optional, &mut direct);
}
let mut importers = BTreeMap::new();
importers.insert(".".to_string(), direct);
Ok(LockfileGraph {
importers,
packages,
..Default::default()
})
}
fn split_berry_header(header: &str) -> Vec<String> {
header
.split(", ")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn parse_berry_spec(spec: &str) -> Option<(&str, &str, &str)> {
let (name, after_at) = if let Some(rest) = spec.strip_prefix('@') {
let slash = rest.find('/')?;
let after_slash = &rest[slash + 1..];
let at = after_slash.find('@')?;
let full_name_len = 1 + slash + 1 + at;
(&spec[..full_name_len], &spec[full_name_len + 1..])
} else {
let at = spec.find('@')?;
(&spec[..at], &spec[at + 1..])
};
let colon = after_at.find(':')?;
let protocol = &after_at[..colon];
let body = &after_at[colon + 1..];
Some((name, protocol, body))
}
fn berry_spec_candidates(name: &str, range: &str) -> Vec<String> {
let mut out = Vec::with_capacity(2);
out.push(format!("{name}@{range}"));
if !range_has_protocol(range) {
out.push(format!("{name}@npm:{range}"));
}
out
}
fn range_has_protocol(range: &str) -> bool {
let Some(colon) = range.find(':') else {
return false;
};
let head = &range[..colon];
!head.is_empty() && head.chars().all(|c| c.is_ascii_alphabetic() || c == '+')
}
fn yaml_scalar_as_string(v: &yaml_serde::Value) -> Option<String> {
match v {
yaml_serde::Value::String(s) => Some(s.clone()),
yaml_serde::Value::Number(n) => Some(n.to_string()),
yaml_serde::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn collect_dep_map(block: &yaml_serde::Mapping, key: &str) -> BTreeMap<String, String> {
block
.get(key)
.and_then(|v| v.as_mapping())
.map(|m| {
m.iter()
.filter_map(|(k, v)| Some((k.as_str()?.to_string(), yaml_scalar_as_string(v)?)))
.collect()
})
.unwrap_or_default()
}
fn collect_peer_meta(block: &yaml_serde::Mapping) -> BTreeMap<String, crate::PeerDepMeta> {
block
.get("peerDependenciesMeta")
.and_then(|v| v.as_mapping())
.map(|m| {
m.iter()
.filter_map(|(k, v)| {
let name = k.as_str()?.to_string();
let meta = v.as_mapping()?;
let optional = meta
.get("optional")
.and_then(|o| o.as_bool())
.unwrap_or(false);
Some((name, crate::PeerDepMeta { optional }))
})
.collect()
})
.unwrap_or_default()
}
fn file_protocol_source(body: &str) -> LocalSource {
let path = PathBuf::from(strip_hash_fragment(body));
let is_tarball = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("tgz") || e.eq_ignore_ascii_case("gz"));
if is_tarball {
LocalSource::Tarball(path)
} else {
LocalSource::Directory(path)
}
}
fn strip_hash_fragment(s: &str) -> &str {
s.split_once('#').map(|(a, _)| a).unwrap_or(s)
}
fn extract_commit_hash(url: &str) -> Option<String> {
url.split_once('#')
.and_then(|(_, b)| crate::normalize_git_fragment(b))
}
fn strip_commit_hash(url: &str) -> String {
strip_hash_fragment(url).to_string()
}
fn classify_remote(url: &str, _block: &yaml_serde::Mapping) -> Option<LocalSource> {
if url.ends_with(".git") || url.contains(".git#") {
Some(LocalSource::Git(GitSource {
url: strip_commit_hash(url),
committish: None,
resolved: extract_commit_hash(url).unwrap_or_default(),
subpath: None,
}))
} else {
Some(LocalSource::RemoteTarball(RemoteTarballSource {
url: strip_commit_hash(url),
integrity: String::new(),
}))
}
}
pub fn write_berry(
path: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<(), Error> {
let canonical = crate::build_canonical_map(graph);
let mut extra_specs: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
let manifest_ranges: Vec<(String, String)> = manifest
.dependencies
.iter()
.chain(manifest.dev_dependencies.iter())
.chain(manifest.optional_dependencies.iter())
.chain(manifest.peer_dependencies.iter())
.map(|(n, r)| (n.clone(), r.clone()))
.collect();
let format_berry_spec = |dep_name: &str, range: &str| {
if range_has_protocol(range) {
format!("{dep_name}@{range}")
} else {
format!("{dep_name}@npm:{range}")
}
};
for dep in graph.importers.get(".").into_iter().flatten() {
let canonical_key = crate::npm::canonical_key_from_dep_path(&dep.dep_path);
if !canonical.contains_key(&canonical_key) {
continue;
}
let Some((_, range)) = manifest_ranges.iter().find(|(n, _)| n == &dep.name) else {
continue;
};
let manifest_spec = format_berry_spec(&dep.name, range);
let exact_spec = berry_exact_spec(canonical.get(&canonical_key).copied().unwrap());
if manifest_spec != exact_spec {
extra_specs
.entry(canonical_key)
.or_default()
.insert(manifest_spec);
}
}
for pkg in canonical.values() {
for (dep_name, range) in &pkg.declared_dependencies {
let Some(resolved_value) = pkg.dependencies.get(dep_name) else {
continue;
};
let target = crate::npm::child_canonical_key(dep_name, resolved_value);
let Some(target_pkg) = canonical.get(&target) else {
continue;
};
let manifest_spec = format_berry_spec(dep_name, range);
let exact_spec = berry_exact_spec(target_pkg);
if manifest_spec != exact_spec {
extra_specs.entry(target).or_default().insert(manifest_spec);
}
}
}
let mut out = String::with_capacity(canonical.len().saturating_mul(256).max(4096));
out.push_str("# This file is generated by running \"yarn install\" inside your project.\n");
out.push_str("# Manual changes might be lost - proceed with caution!\n\n");
out.push_str("__metadata:\n version: 8\n cacheKey: 10c0\n\n");
for (canonical_key, pkg) in &canonical {
let exact_spec = berry_exact_spec(pkg);
let mut header_specs: Vec<String> = vec![exact_spec.clone()];
if let Some(extras) = extra_specs.get(canonical_key) {
for s in extras {
if !header_specs.contains(s) {
header_specs.push(s.clone());
}
}
}
let header_inner = header_specs.join(", ");
out.push_str("e_yaml_scalar(&header_inner));
out.push_str(":\n");
out.push_str(" version: ");
out.push_str("e_yaml_scalar(&pkg.version));
out.push('\n');
out.push_str(" resolution: ");
out.push_str("e_yaml_scalar(&exact_spec));
out.push('\n');
write_berry_dep_map(
&mut out,
"dependencies",
&pkg.dependencies,
&pkg.declared_dependencies,
&canonical,
);
write_berry_dep_map(
&mut out,
"optionalDependencies",
&pkg.optional_dependencies,
&pkg.declared_dependencies,
&canonical,
);
write_berry_peer_deps(&mut out, &pkg.peer_dependencies);
write_berry_peer_meta(&mut out, &pkg.peer_dependencies_meta);
if let Some(checksum) = &pkg.yarn_checksum {
out.push_str(" checksum: ");
out.push_str("e_yaml_scalar(checksum));
out.push('\n');
}
out.push_str(" languageName: node\n");
let link_type = match &pkg.local_source {
Some(LocalSource::Link(_)) => "soft",
_ => "hard",
};
out.push_str(" linkType: ");
out.push_str(link_type);
out.push_str("\n\n");
}
crate::atomic_write_lockfile(path, out.as_bytes())?;
Ok(())
}
fn berry_exact_spec(pkg: &LockedPackage) -> String {
match &pkg.local_source {
None => format!("{}@npm:{}", pkg.name, pkg.version),
Some(src) => format!("{}@{}", pkg.name, src.specifier()),
}
}
fn write_berry_dep_map(
out: &mut String,
section: &str,
deps: &BTreeMap<String, String>,
declared: &BTreeMap<String, String>,
canonical: &BTreeMap<String, &LockedPackage>,
) {
let resolved: Vec<(&str, String)> = deps
.iter()
.filter_map(|(n, v)| {
let key = crate::npm::child_canonical_key(n, v);
let target = canonical.get(&key)?;
let spec_body = match &target.local_source {
None => {
let body = declared
.get(n)
.cloned()
.unwrap_or_else(|| crate::npm::dep_value_as_version(n, v).to_string());
if body.contains(':') {
body
} else {
format!("npm:{body}")
}
}
Some(src) => src.specifier(),
};
Some((n.as_str(), spec_body))
})
.collect();
if resolved.is_empty() {
return;
}
out.push_str(" ");
out.push_str(section);
out.push_str(":\n");
for (name, body) in resolved {
out.push_str(" ");
out.push_str("e_yaml_key(name));
out.push_str(": ");
out.push_str("e_yaml_scalar(&body));
out.push('\n');
}
}
fn write_berry_peer_deps(out: &mut String, peer: &BTreeMap<String, String>) {
if peer.is_empty() {
return;
}
out.push_str(" peerDependencies:\n");
for (name, range) in peer {
out.push_str(" ");
out.push_str("e_yaml_key(name));
out.push_str(": ");
out.push_str("e_yaml_scalar(range));
out.push('\n');
}
}
fn write_berry_peer_meta(out: &mut String, meta: &BTreeMap<String, crate::PeerDepMeta>) {
if meta.is_empty() {
return;
}
out.push_str(" peerDependenciesMeta:\n");
for (name, m) in meta {
out.push_str(" ");
out.push_str("e_yaml_key(name));
out.push_str(":\n");
out.push_str(" optional: ");
out.push_str(if m.optional { "true" } else { "false" });
out.push('\n');
}
}
fn quote_yaml_scalar(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn quote_yaml_key(s: &str) -> String {
quote_yaml_scalar(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_manifest(deps: &[(&str, &str)], dev: &[(&str, &str)]) -> aube_manifest::PackageJson {
aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: deps
.iter()
.map(|(n, r)| (n.to_string(), r.to_string()))
.collect(),
dev_dependencies: dev
.iter()
.map(|(n, r)| (n.to_string(), r.to_string()))
.collect(),
peer_dependencies: Default::default(),
optional_dependencies: Default::default(),
update_config: None,
scripts: Default::default(),
engines: Default::default(),
workspaces: None,
bundled_dependencies: None,
extra: Default::default(),
}
}
#[test]
fn test_parse_spec_name() {
assert_eq!(parse_spec_name("foo@^1.0.0"), Some("foo".to_string()));
assert_eq!(parse_spec_name("foo@1.2.3"), Some("foo".to_string()));
assert_eq!(
parse_spec_name("@scope/pkg@^1.0.0"),
Some("@scope/pkg".to_string())
);
assert_eq!(parse_spec_name("foo"), None);
}
#[test]
fn test_parse_simple() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"# yarn lockfile v1
foo@^1.0.0:
version "1.2.3"
resolved "https://example.com/foo-1.2.3.tgz"
integrity sha512-aaa
dependencies:
bar "^2.0.0"
bar@^2.0.0:
version "2.5.0"
resolved "https://example.com/bar-2.5.0.tgz"
integrity sha512-bbb
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("foo", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert_eq!(graph.packages.len(), 2);
assert!(graph.packages.contains_key("foo@1.2.3"));
assert!(graph.packages.contains_key("bar@2.5.0"));
let foo = &graph.packages["foo@1.2.3"];
assert_eq!(foo.integrity.as_deref(), Some("sha512-aaa"));
assert_eq!(
foo.dependencies.get("bar").map(String::as_str),
Some("bar@2.5.0")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "foo");
assert_eq!(root[0].dep_path, "foo@1.2.3");
}
#[test]
fn test_parse_scoped_and_multi_spec() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"# yarn lockfile v1
"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":
version "1.1.0"
integrity sha512-zzz
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("@scope/pkg", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert!(graph.packages.contains_key("@scope/pkg@1.1.0"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root[0].name, "@scope/pkg");
assert_eq!(root[0].dep_path, "@scope/pkg@1.1.0");
}
#[test]
fn test_detect_berry_vs_classic() {
assert!(is_berry("__metadata:\n version: 6\n"));
assert!(is_berry("# comment\n__metadata:\n version: 8\n"));
assert!(!is_berry(
"# yarn lockfile v1\n\nfoo@^1.0.0:\n version \"1.0.0\"\n"
));
}
#[test]
fn test_write_roundtrip() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"# yarn lockfile v1
foo@^1.0.0:
version "1.2.3"
integrity sha512-foo
dependencies:
bar "^2.0.0"
bar@^2.0.0:
version "2.5.0"
integrity sha512-bar
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("foo", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
let out = tempfile::NamedTempFile::new().unwrap();
write_classic(out.path(), &graph, &manifest).unwrap();
let reparsed_manifest = make_manifest(&[], &[]);
let reparsed = parse(out.path(), &reparsed_manifest).unwrap();
assert!(reparsed.packages.contains_key("foo@1.2.3"));
assert!(reparsed.packages.contains_key("bar@2.5.0"));
assert_eq!(
reparsed.packages["foo@1.2.3"].integrity.as_deref(),
Some("sha512-foo")
);
assert_eq!(
reparsed.packages["foo@1.2.3"]
.dependencies
.get("bar")
.map(String::as_str),
Some("bar@2.5.0")
);
}
#[test]
fn test_dev_dep_classification() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"foo@^1.0.0:
version "1.0.0"
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[], &[("foo", "^1.0.0")]);
let graph = parse(tmp.path(), &manifest).unwrap();
let root = graph.importers.get(".").unwrap();
assert_eq!(root[0].dep_type, DepType::Dev);
}
#[test]
fn test_parse_berry_spec() {
assert_eq!(
parse_berry_spec("lodash@npm:^4.17.0"),
Some(("lodash", "npm", "^4.17.0"))
);
assert_eq!(
parse_berry_spec("@types/node@npm:20.1.0"),
Some(("@types/node", "npm", "20.1.0"))
);
assert_eq!(
parse_berry_spec("my-pkg@workspace:."),
Some(("my-pkg", "workspace", "."))
);
assert_eq!(parse_berry_spec("no-protocol"), None);
}
#[test]
fn test_split_berry_header() {
let specs = split_berry_header("lodash@npm:^4.17.0, lodash@npm:^4.18.0");
assert_eq!(
specs,
vec![
"lodash@npm:^4.17.0".to_string(),
"lodash@npm:^4.18.0".to_string()
]
);
let single = split_berry_header("foo@npm:1.0.0");
assert_eq!(single, vec!["foo@npm:1.0.0".to_string()]);
}
#[test]
fn test_range_has_protocol() {
assert!(range_has_protocol("npm:^1.0.0"));
assert!(range_has_protocol("workspace:*"));
assert!(range_has_protocol("file:./pkgs/foo"));
assert!(range_has_protocol("patch:react@^18.0.0#./mypatch.patch"));
assert!(range_has_protocol("git+ssh://git@github.com/u/r.git"));
assert!(range_has_protocol("git+https://github.com/u/r.git"));
assert!(range_has_protocol("git+file:./vendored.git"));
assert!(!range_has_protocol("^1.0.0"));
assert!(!range_has_protocol("1.2.3"));
assert!(!range_has_protocol(">=1.0 <2.0"));
}
#[test]
fn test_parse_berry_simple() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"foo@npm:^1.0.0":
version: 1.2.3
resolution: "foo@npm:1.2.3"
dependencies:
bar: "npm:^2.0.0"
checksum: 10c0/abcdef
languageName: node
linkType: hard
"bar@npm:^2.0.0":
version: 2.5.0
resolution: "bar@npm:2.5.0"
checksum: 10c0/123456
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("foo", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert_eq!(graph.packages.len(), 2);
let foo = &graph.packages["foo@1.2.3"];
assert_eq!(foo.version, "1.2.3");
assert_eq!(foo.yarn_checksum.as_deref(), Some("10c0/abcdef"));
assert_eq!(
foo.dependencies.get("bar").map(String::as_str),
Some("bar@2.5.0")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "foo");
assert_eq!(root[0].dep_path, "foo@1.2.3");
}
#[test]
fn test_parse_berry_scoped_and_multi_spec() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"__metadata:
version: 8
cacheKey: 10c0
"@scope/pkg@npm:^1.0.0, @scope/pkg@npm:^1.1.0":
version: 1.1.0
resolution: "@scope/pkg@npm:1.1.0"
checksum: 10c0/zzz
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("@scope/pkg", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert!(graph.packages.contains_key("@scope/pkg@1.1.0"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root[0].name, "@scope/pkg");
assert_eq!(root[0].dep_path, "@scope/pkg@1.1.0");
}
#[test]
fn test_parse_berry_skips_workspace_root() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"__metadata:
version: 8
cacheKey: 10c0
"my-project@workspace:.":
version: 0.0.0-use.local
resolution: "my-project@workspace:."
dependencies:
foo: "npm:^1.0.0"
languageName: unknown
linkType: soft
"foo@npm:^1.0.0":
version: 1.0.0
resolution: "foo@npm:1.0.0"
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("foo", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert_eq!(graph.packages.len(), 1);
assert!(graph.packages.contains_key("foo@1.0.0"));
assert!(!graph.packages.contains_key("my-project@0.0.0-use.local"));
}
#[test]
fn test_parse_berry_unquoted_numeric_version() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"__metadata:
version: 8
cacheKey: 10c0
"int-version@npm:5":
version: 5
resolution: "int-version@npm:5"
languageName: node
linkType: hard
"two-part@npm:1.0":
version: 1.0
resolution: "two-part@npm:1.0"
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert!(graph.packages.contains_key("int-version@5"));
assert!(graph.packages.contains_key("two-part@1.0"));
assert_eq!(graph.packages["int-version@5"].version, "5");
assert_eq!(graph.packages["two-part@1.0"].version, "1.0");
}
#[test]
fn test_parse_berry_typed_dep_values() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"__metadata:
version: 8
cacheKey: 10c0
"foo@npm:^1.0.0":
version: 1.0.0
resolution: "foo@npm:1.0.0"
peerDependencies:
numeric-peer: 5
bool-peer: true
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
let foo = &graph.packages["foo@1.0.0"];
assert_eq!(
foo.peer_dependencies
.get("numeric-peer")
.map(String::as_str),
Some("5")
);
assert_eq!(
foo.peer_dependencies.get("bool-peer").map(String::as_str),
Some("true")
);
}
#[test]
fn test_parse_berry_http_and_git_protocols() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"__metadata:
version: 8
cacheKey: 10c0
"tarball-pkg@https://example.com/pkg-1.0.0.tgz":
version: 1.0.0
resolution: "tarball-pkg@https://example.com/pkg-1.0.0.tgz"
languageName: node
linkType: hard
"git-pkg@https://github.com/user/repo.git#commit=abcdef0123456789abcdef0123456789abcdef01":
version: 2.0.0
resolution: "git-pkg@https://github.com/user/repo.git#commit=abcdef0123456789abcdef0123456789abcdef01"
languageName: node
linkType: hard
"ssh-git-pkg@git+ssh://git@github.com/user/other.git#deadbeef":
version: 3.0.0
resolution: "ssh-git-pkg@git+ssh://git@github.com/user/other.git#deadbeef"
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
assert_eq!(graph.packages.len(), 3);
let by_name: BTreeMap<&str, &LockedPackage> = graph
.packages
.values()
.map(|p| (p.name.as_str(), p))
.collect();
let tar = by_name["tarball-pkg"];
assert!(matches!(
&tar.local_source,
Some(LocalSource::RemoteTarball(_))
));
let git = by_name["git-pkg"];
let Some(LocalSource::Git(git)) = &git.local_source else {
panic!("expected git LocalSource");
};
assert_eq!(git.url, "https://github.com/user/repo.git");
assert_eq!(git.resolved, "abcdef0123456789abcdef0123456789abcdef01");
let ssh = by_name["ssh-git-pkg"];
assert!(matches!(&ssh.local_source, Some(LocalSource::Git(_))));
}
#[test]
fn test_write_berry_roundtrip() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"__metadata:
version: 8
cacheKey: 10c0
"foo@npm:^1.0.0":
version: 1.2.3
resolution: "foo@npm:1.2.3"
dependencies:
bar: "npm:^2.0.0"
checksum: 10c0/foohash
languageName: node
linkType: hard
"bar@npm:^2.0.0":
version: 2.5.0
resolution: "bar@npm:2.5.0"
checksum: 10c0/barhash
languageName: node
linkType: hard
"#;
std::fs::write(tmp.path(), content).unwrap();
let manifest = make_manifest(&[("foo", "^1.0.0")], &[]);
let graph = parse(tmp.path(), &manifest).unwrap();
let out = tempfile::NamedTempFile::new().unwrap();
write_berry(out.path(), &graph, &manifest).unwrap();
let written = std::fs::read_to_string(out.path()).unwrap();
assert!(is_berry(&written));
let reparsed_manifest = make_manifest(&[("foo", "^1.0.0")], &[]);
let reparsed = parse(out.path(), &reparsed_manifest).unwrap();
assert!(reparsed.packages.contains_key("foo@1.2.3"));
assert!(reparsed.packages.contains_key("bar@2.5.0"));
assert_eq!(
reparsed.packages["foo@1.2.3"].yarn_checksum.as_deref(),
Some("10c0/foohash")
);
assert_eq!(
reparsed.packages["foo@1.2.3"]
.dependencies
.get("bar")
.map(String::as_str),
Some("bar@2.5.0")
);
let root = reparsed.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].dep_path, "foo@1.2.3");
}
#[test]
fn test_write_berry_link_type_soft_for_link_deps() {
let mut packages = BTreeMap::new();
packages.insert(
"linked-pkg@1.0.0".to_string(),
LockedPackage {
name: "linked-pkg".to_string(),
version: "1.0.0".to_string(),
dep_path: "linked-pkg@1.0.0".to_string(),
local_source: Some(LocalSource::Link(PathBuf::from("./vendor/linked-pkg"))),
..Default::default()
},
);
packages.insert(
"regular-pkg@2.0.0".to_string(),
LockedPackage {
name: "regular-pkg".to_string(),
version: "2.0.0".to_string(),
dep_path: "regular-pkg@2.0.0".to_string(),
..Default::default()
},
);
let graph = LockfileGraph {
importers: {
let mut m = BTreeMap::new();
m.insert(".".to_string(), vec![]);
m
},
packages,
..Default::default()
};
let manifest = make_manifest(&[], &[]);
let out = tempfile::NamedTempFile::new().unwrap();
write_berry(out.path(), &graph, &manifest).unwrap();
let written = std::fs::read_to_string(out.path()).unwrap();
let linked_idx = written.find("linked-pkg@").unwrap();
let regular_idx = written.find("regular-pkg@").unwrap();
let linked_block = &written[linked_idx..regular_idx];
let regular_block = &written[regular_idx..];
assert!(
linked_block.contains("linkType: soft"),
"link: block should be soft-linked:\n{linked_block}"
);
assert!(
regular_block.contains("linkType: hard"),
"registry block should be hard-linked:\n{regular_block}"
);
}
#[test]
fn test_write_berry_escapes_resolution_and_header() {
let mut packages = BTreeMap::new();
packages.insert(
"weird-pkg@1.0.0".to_string(),
LockedPackage {
name: "weird-pkg".to_string(),
version: "1.0.0".to_string(),
dep_path: "weird-pkg@1.0.0".to_string(),
local_source: Some(LocalSource::Directory(PathBuf::from("./a\\b/c"))),
..Default::default()
},
);
let graph = LockfileGraph {
importers: {
let mut m = BTreeMap::new();
m.insert(".".to_string(), vec![]);
m
},
packages,
..Default::default()
};
let manifest = make_manifest(&[], &[]);
let out = tempfile::NamedTempFile::new().unwrap();
write_berry(out.path(), &graph, &manifest).unwrap();
let written = std::fs::read_to_string(out.path()).unwrap();
let _doc: yaml_serde::Value = yaml_serde::from_str(&written)
.unwrap_or_else(|e| panic!("berry writer produced malformed YAML: {e}\n{written}"));
}
}