use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use thiserror::Error;
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Error {
#[error("could not determine current directory: {0}")]
CurrentDir(#[source] std::io::Error),
#[error("could not read pnpm-lock.yaml: {0}")]
ReadLockfile(#[source] std::io::Error),
#[error("could not parse lockfile structure: {0}")]
ParseLockfile(#[source] serde_yaml::Error),
#[error("Unexpected lockfile content")]
UnexpectedLockfileContent,
}
#[derive(Debug, serde::Deserialize)]
#[non_exhaustive]
#[serde(tag = "lockfileVersion")]
pub enum Lockfile {
#[serde(rename = "9.0")]
V9 {
importers: HashMap<String, Importer>,
snapshots: HashMap<String, Snapshot>,
},
}
impl Lockfile {
pub fn read_from_workspace_dir(workspace_dir: &std::path::Path) -> Result<Self> {
let data =
std::fs::read(workspace_dir.join("pnpm-lock.yaml")).map_err(Error::ReadLockfile)?;
Self::from_slice(&data)
}
pub fn from_slice(data: &[u8]) -> Result<Self> {
let result: Self = serde_yaml::from_slice(data).map_err(Error::ParseLockfile)?;
Ok(result)
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Importer {
#[serde(default)]
pub dependencies: HashMap<String, Dependency>,
#[serde(default)]
pub dev_dependencies: HashMap<String, Dependency>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Dependency {
pub specifier: String,
pub version: String,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub dependencies: HashMap<String, String>,
#[serde(default)]
pub optional_dependencies: HashMap<String, String>,
#[serde(default)]
pub transitive_peer_dependencies: Vec<String>,
}
pub fn print_tree(workspace_dir: &Path, name: &str) -> Result<()> {
let lockfile = Lockfile::read_from_workspace_dir(workspace_dir)?;
let graph = DependencyGraph::from_lockfile(&lockfile, workspace_dir)?;
let mut seen = HashSet::<NodeId>::new();
fn print_tree_inner(
inverse_deps: &DependencyGraph,
seen: &mut HashSet<NodeId>,
node_id: &NodeId,
depth: usize,
) {
if !seen.insert(node_id.clone()) {
println!("{:indent$}{node_id} (*)", "", indent = depth * 2,);
return;
}
let Some(dep_ids) = inverse_deps.inverse.get(node_id) else {
println!("{:indent$}{node_id}", "", indent = depth * 2,);
return;
};
println!("{:indent$}{node_id}:", "", indent = depth * 2,);
for dep_id in dep_ids {
print_tree_inner(inverse_deps, seen, dep_id, depth + 1);
}
}
for node_id in graph.inverse.keys() {
if matches!(node_id, NodeId::Package { name: package_name, .. } if name == package_name) {
print_tree_inner(&graph, &mut seen, node_id, 0);
}
}
Ok(())
}
#[derive(Default)]
pub struct DependencyGraph {
pub forward: HashMap<NodeId, HashSet<NodeId>>,
pub inverse: HashMap<NodeId, HashSet<NodeId>>,
}
impl DependencyGraph {
pub fn from_lockfile(lockfile: &Lockfile, workspace_dir: &Path) -> Result<Self> {
let Lockfile::V9 {
importers,
snapshots,
} = lockfile;
let mut forward = HashMap::<NodeId, HashSet<NodeId>>::new();
let mut inverse = HashMap::<NodeId, HashSet<NodeId>>::new();
for (path, entry) in importers {
let path = workspace_dir.join(path);
let node_id = NodeId::Importer { path: path.clone() };
for (dep_name, dep) in entry
.dependencies
.iter()
.chain(entry.dev_dependencies.iter())
{
let dep_id = if let Some(link_path) = dep.version.strip_prefix("link:") {
NodeId::Importer {
path: path.join(link_path),
}
} else {
NodeId::Package {
name: dep_name.clone(),
version: dep.version.clone(),
}
};
forward
.entry(node_id.clone())
.or_default()
.insert(dep_id.clone());
inverse.entry(dep_id).or_default().insert(node_id.clone());
}
}
for (id, entry) in snapshots {
let split = 1 + id[1..].find('@').ok_or(Error::UnexpectedLockfileContent)?;
let node_id = NodeId::Package {
name: id[..split].to_string(),
version: id[split + 1..].to_string(),
};
for (dep_name, dep_version) in &entry.dependencies {
let dep_id = NodeId::Package {
name: dep_name.clone(),
version: dep_version.clone(),
};
forward
.entry(node_id.clone())
.or_default()
.insert(dep_id.clone());
inverse.entry(dep_id).or_default().insert(node_id.clone());
}
}
Ok(Self { forward, inverse })
}
}
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
pub enum NodeId {
Importer {
path: PathBuf,
},
Package {
name: String,
version: String,
},
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NodeId::Importer { path } => write!(f, "{}", path.display()),
NodeId::Package { name, version } => write!(f, "{}@{}", name, version),
}
}
}