use crate::{DepType, DirectDep, Error, LockedPackage, LockfileGraph};
use std::collections::BTreeMap;
use std::path::Path;
pub(super) 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 alias_of = block
.specs
.iter()
.find_map(|s| parse_npm_alias_real_name(s))
.filter(|real| real.as_str() != name);
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,
alias_of: alias_of.clone(),
..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(),
crate::npm::dep_path_tail(name, resolved_path).to_string(),
);
}
}
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 = unquote_yarn_scalar(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();
Some((
unquote_yarn_scalar(key).to_string(),
unquote_yarn_scalar(value).to_string(),
))
}
fn unquote_yarn_scalar(value: &str) -> &str {
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
}
}
pub(super) 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(super) fn parse_npm_alias_real_name(spec: &str) -> Option<String> {
let after_alias = if let Some(rest) = spec.strip_prefix('@') {
let slash = rest.find('/')?;
let after_slash = &rest[slash + 1..];
let at = after_slash.find('@')?;
&after_slash[at + 1..]
} else {
let at = spec.find('@')?;
&spec[at + 1..]
};
let after_protocol = after_alias.strip_prefix("npm:")?;
if let Some(rest) = after_protocol.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 = after_protocol.find('@')?;
Some(after_protocol[..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(())
}