From 21b086526617dc505df42563c8565da2438b3ff2 Mon Sep 17 00:00:00 2001 From: Simon Buchan Date: Tue, 24 Sep 2024 20:33:30 +1200 Subject: [PATCH] feat: expose more tree internals, add --dir --- Cargo.lock | 9 +- Cargo.toml | 1 + src/lib.rs | 5 +- src/main.rs | 11 ++- src/tree.rs | 280 ++++++++++++++++++++++++++++++++++++---------------- 5 files changed, 211 insertions(+), 95 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13c6ffe..d33c2b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,6 +243,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "thiserror", ] [[package]] @@ -365,18 +366,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6c1eae8..c0dda3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ serde = { version = "1.0.206", features = ["derive"] } serde_json = "1.0.124" serde_yaml = "0.9.34" anyhow = "1.0.86" +thiserror = "1.0.64" [lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } diff --git a/src/lib.rs b/src/lib.rs index e8483e2..4975d85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,9 +11,8 @@ use anyhow::{bail, Context as _, Result}; -mod tree; - -pub use tree::print_tree; +/// `pnpm-extra tree` implementation details. +pub mod tree; /// Parse and return the content of pnpm-workspace.yaml as a serde_yaml::Mapping. /// diff --git a/src/main.rs b/src/main.rs index a574a6d..4538cb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,13 +2,19 @@ use anyhow::Result; use clap::Parser; +use std::path::PathBuf; #[derive(Parser)] enum Args { /// better pnpm why, modelled on cargo tree -i Tree { #[clap(name = "name")] + /// Package name to show the tree for name: String, + + #[clap(short, long, default_value = ".")] + /// Workspace directory + dir: PathBuf, }, #[clap(subcommand)] @@ -19,8 +25,9 @@ mod catalog; fn main() -> Result<()> { match Args::parse() { - Args::Tree { name } => { - pnpm_extra::print_tree(&name)?; + Args::Tree { name, dir } => { + let dir = std::path::absolute(dir)?; + pnpm_extra::tree::print_tree(&dir, &name)?; Ok(()) } Args::Catalog(args) => { diff --git a/src/tree.rs b/src/tree.rs index ea868d7..d1fbd09 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1,8 +1,35 @@ -use anyhow::{Context as _, Result}; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// The result type for `tree` functionality. +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +#[non_exhaustive] +/// The error type for `tree` functionality. +pub enum Error { + #[error("could not determine current directory: {0}")] + /// Error when the current directory cannot be determined. + CurrentDir(#[source] std::io::Error), + + #[error("could not read pnpm-lock.yaml: {0}")] + /// Error when the pnpm-lock.yaml file cannot be read. + ReadLockfile(#[source] std::io::Error), + + #[error("could not parse lockfile structure: {0}")] + /// Error when the pnpm-lock.yaml file cannot be parsed. + ParseLockfile(#[source] serde_yaml::Error), + + #[error("Unexpected lockfile content")] + /// Error when the lockfile content could not be understood. + /// Currently, this is only when the snapshot key cannot be split into a package name and + /// version. + UnexpectedLockfileContent, +} #[derive(Debug, serde::Deserialize)] +#[non_exhaustive] #[serde(tag = "lockfileVersion")] /// A subset of the pnpm-lock.yaml file format. pub enum Lockfile { @@ -11,22 +38,46 @@ pub enum Lockfile { /// https://github.com/orgs/pnpm/discussions/6857 V9 { /// Importers describe the packages in the workspace and their resolved dependencies. + /// /// The key is a relative path to the directory containing the package.json, e.g.: /// "packages/foo", or "." for the workspace root. importers: HashMap, + /// Snapshots describe the packages in the store (e.g. from the registry) and their /// resolved dependencies. /// - /// The key is the package name and qualified version, e.g.: - /// "foo@1.2.3", "bar@4.5.6(peer@7.8.9)", and so on. + /// The key is the package name and qualified version, e.g.: "foo@1.2.3", + /// "bar@4.5.6(peer@7.8.9)", and so on (pnpm code refers to this as the "depPath"). /// - /// Note that this key also currently serves as the directory entry in the store, e.g. - /// "node_modules/.pnpm/{key}" (which then contains a `node_modules` directory to implement - /// the dependency resolution). + /// Note that this key also currently serves as the directory entry in the virtual store, + /// e.g. "node_modules/.pnpm/{key}", see: https://pnpm.io/how-peers-are-resolved snapshots: HashMap, }, } +impl Lockfile { + /// Read the content of a pnpm-lock.yaml file. + /// + /// # Errors + /// - [`Error::ReadLockfile`], if the `pnpm-lock.yaml` file cannot be read from the provided + /// workspace directory. + /// - [`Error::ParseLockfile`], if the data cannot be parsed as a `Lockfile`. + pub fn read_from_workspace_dir(workspace_dir: &std::path::Path) -> Result { + let data = + std::fs::read(workspace_dir.join("pnpm-lock.yaml")).map_err(Error::ReadLockfile)?; + Self::from_slice(&data) + } + + /// Parse the content of a pnpm-lock.yaml file. + /// + /// # Errors + /// - [`Error::ParseLockfile`], if the data cannot be parsed as a `Lockfile`. + pub fn from_slice(data: &[u8]) -> Result { + let result: Self = serde_yaml::from_slice(data).map_err(Error::ParseLockfile)?; + Ok(result) + } +} + #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] /// An importer represents a package in the workspace. @@ -35,6 +86,7 @@ pub struct Importer { /// The resolutions of the `dependencies` entry in the package.json. /// The key is the package name. pub dependencies: HashMap, + #[serde(default)] /// The resolutions of the `devDependencies` entry in the package.json. /// The key is the package name. @@ -44,10 +96,14 @@ pub struct Importer { #[derive(Debug, serde::Deserialize)] /// A dependency represents a resolved dependency for a Importer (workspace package) pub struct Dependency { - // specifier: String, + /// The specifier from the package.json, e.g. "^1.2.3", "workspace:^", etc. + pub specifier: String, + /// The resolved version of the dependency. + /// /// This will be either a qualified version that together with the package name forms a key /// into the snapshots map, or a "link:" for workspace packages, e.g.: + /// /// ```yaml /// ... /// importers: @@ -70,12 +126,15 @@ pub struct Dependency { } #[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] /// A snapshot represents a package in the store. pub struct Snapshot { + #[serde(default)] + /// If the package is only used in optional dependencies. + pub optional: bool, + #[serde(default)] /// The resolved dependencies of the package, a map from package name to qualified version. - /// No distinction is made between different dependency kinds here, e.g. `dependencies` vs - /// `peerDependencies` (and of course, `devDependencies` are not considered). /// ```yaml /// ... /// snapshots: @@ -86,6 +145,16 @@ pub struct Snapshot { /// ... /// ``` pub dependencies: HashMap, + + #[serde(default)] + /// As with `dependencies`, but for optional dependencies (including optional peer + /// dependencies). + pub optional_dependencies: HashMap, + + #[serde(default)] + /// The package names of peer dependencies of the transitive package dependencies, + /// excluding direct peer dependencies. + pub transitive_peer_dependencies: Vec, } /// Performs the `pnpm tree {name}` CLI command, printing a user-friendly inverse dependency tree @@ -94,85 +163,20 @@ pub struct Snapshot { /// The output format is not specified and may change without a breaking change. /// /// # Errors -/// - If the current directory cannot be determined. -/// - If the pnpm-lock.yaml file cannot be read or parsed. -pub fn print_tree(name: &str) -> Result<()> { - let root = std::env::current_dir().context("getting current directory")?; - - let Lockfile::V9 { - importers, - snapshots, - } = serde_yaml::from_slice::( - &std::fs::read(root.join("pnpm-lock.yaml")).context("reading pnpm-lock.yaml")?, - ) - .context("parsing pnpm-lock.yaml")?; - - // Invert the dependency graph. - #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)] - 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), - } - } - } - - let mut inverse_deps = HashMap::>::new(); - - for (path, entry) in importers { - let path = root.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(), - } - }; - inverse_deps - .entry(dep_id) - .or_default() - .insert(node_id.clone()); - } - } +/// - [`Error::ReadLockfile`] If the pnpm-lock.yaml file cannot be read. +/// - [`Error::ParseLockfile`] If the pnpm-lock.yaml file cannot be parsed. +/// - [`Error::UnexpectedLockfileContent`] If the lockfile content could not otherwise be +/// understood. +pub fn print_tree(workspace_dir: &Path, name: &str) -> Result<()> { + let lockfile = Lockfile::read_from_workspace_dir(workspace_dir)?; - for (id, entry) in snapshots { - let split = 1 + id[1..].find('@').context("missing @ in id")?; - 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(), - }; - inverse_deps - .entry(dep_id) - .or_default() - .insert(node_id.clone()); - } - } + let graph = DependencyGraph::from_lockfile(&lockfile, workspace_dir)?; // Print the tree, skipping repeated nodes. let mut seen = HashSet::::new(); fn print_tree_inner( - inverse_deps: &HashMap>, + inverse_deps: &DependencyGraph, seen: &mut HashSet, node_id: &NodeId, depth: usize, @@ -181,7 +185,7 @@ pub fn print_tree(name: &str) -> Result<()> { println!("{:indent$}{node_id} (*)", "", indent = depth * 2,); return; } - let Some(dep_ids) = inverse_deps.get(node_id) else { + let Some(dep_ids) = inverse_deps.inverse.get(node_id) else { println!("{:indent$}{node_id}", "", indent = depth * 2,); return; }; @@ -191,11 +195,115 @@ pub fn print_tree(name: &str) -> Result<()> { } } - for node_id in inverse_deps.keys() { + for node_id in graph.inverse.keys() { if matches!(node_id, NodeId::Package { name: package_name, .. } if name == package_name) { - print_tree_inner(&inverse_deps, &mut seen, node_id, 0); + print_tree_inner(&graph, &mut seen, node_id, 0); } } Ok(()) } + +#[derive(Default)] +/// A dependency graph. +pub struct DependencyGraph { + /// A map from a node to a set of nodes it depends on. + pub forward: HashMap>, + + /// A map from a node to a set of nodes that depend on it. + pub inverse: HashMap>, +} + +impl DependencyGraph { + /// Construct a [`DependencyGraph`] from a [`Lockfile`]. + /// + /// Computes a forwards and inverse dependency graph from the lockfile, used to print + /// and filter the dependency tree. + /// + /// # Errors + /// - [`Error::UnexpectedLockfileContent`] If the lockfile content could not be understood. + pub fn from_lockfile(lockfile: &Lockfile, workspace_dir: &Path) -> Result { + let Lockfile::V9 { + importers, + snapshots, + } = lockfile; + + let mut forward = HashMap::>::new(); + let mut inverse = HashMap::>::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)] +/// A node in the dependency graph. +pub enum NodeId { + /// A package in the workspace. + Importer { + /// The workspace-relative path to the package directory. + path: PathBuf, + }, + + /// A package from the registry. + Package { + /// The package name. + name: String, + + /// The peer-dependency qualified version. + 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), + } + } +}