pnpm_extra/
tree.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5/// The result type for `tree` functionality.
6pub type Result<T, E = Error> = std::result::Result<T, E>;
7
8#[derive(Debug, Error)]
9#[non_exhaustive]
10/// The error type for `tree` functionality.
11pub enum Error {
12    #[error("could not determine current directory: {0}")]
13    /// Error when the current directory cannot be determined.
14    CurrentDir(#[source] std::io::Error),
15
16    #[error("could not read pnpm-lock.yaml: {0}")]
17    /// Error when the pnpm-lock.yaml file cannot be read.
18    ReadLockfile(#[source] std::io::Error),
19
20    #[error("could not parse lockfile structure: {0}")]
21    /// Error when the pnpm-lock.yaml file cannot be parsed.
22    ParseLockfile(#[source] serde_yaml::Error),
23
24    #[error("Unexpected lockfile content")]
25    /// Error when the lockfile content could not be understood.
26    /// Currently, this is only when the snapshot key cannot be split into a package name and
27    /// version.
28    UnexpectedLockfileContent,
29}
30
31#[derive(Debug, serde::Deserialize)]
32#[non_exhaustive]
33#[serde(tag = "lockfileVersion")]
34/// A subset of the pnpm-lock.yaml file format.
35pub enum Lockfile {
36    #[serde(rename = "9.0")]
37    /// Only supports version 9.0 currently, though apparently versions are backwards compatible?
38    /// https://github.com/orgs/pnpm/discussions/6857
39    V9 {
40        /// Importers describe the packages in the workspace and their resolved dependencies.
41        ///
42        /// The key is a relative path to the directory containing the package.json, e.g.:
43        /// "packages/foo", or "." for the workspace root.
44        importers: HashMap<String, Importer>,
45
46        /// Snapshots describe the packages in the store (e.g. from the registry) and their
47        /// resolved dependencies.
48        ///
49        /// The key is the package name and qualified version, e.g.: "foo@1.2.3",
50        /// "bar@4.5.6(peer@7.8.9)", and so on (pnpm code refers to this as the "depPath").
51        ///
52        /// Note that this key also currently serves as the directory entry in the virtual store,
53        /// e.g. "node_modules/.pnpm/{key}", see: https://pnpm.io/how-peers-are-resolved
54        snapshots: HashMap<String, Snapshot>,
55    },
56}
57
58impl Lockfile {
59    /// Read the content of a pnpm-lock.yaml file.
60    ///
61    /// # Errors
62    /// - [`Error::ReadLockfile`], if the `pnpm-lock.yaml` file cannot be read from the provided
63    ///   workspace directory.
64    /// - [`Error::ParseLockfile`], if the data cannot be parsed as a `Lockfile`.
65    pub fn read_from_workspace_dir(workspace_dir: &std::path::Path) -> Result<Self> {
66        let data =
67            std::fs::read(workspace_dir.join("pnpm-lock.yaml")).map_err(Error::ReadLockfile)?;
68        Self::from_slice(&data)
69    }
70
71    /// Parse the content of a pnpm-lock.yaml file.
72    ///
73    /// # Errors
74    /// - [`Error::ParseLockfile`], if the data cannot be parsed as a `Lockfile`.
75    pub fn from_slice(data: &[u8]) -> Result<Self> {
76        let result: Self = serde_yaml::from_slice(data).map_err(Error::ParseLockfile)?;
77        Ok(result)
78    }
79}
80
81#[derive(Debug, serde::Deserialize)]
82#[serde(rename_all = "camelCase")]
83/// An importer represents a package in the workspace.
84pub struct Importer {
85    #[serde(default)]
86    /// The resolutions of the `dependencies` entry in the package.json.
87    /// The key is the package name.
88    pub dependencies: HashMap<String, Dependency>,
89
90    #[serde(default)]
91    /// The resolutions of the `devDependencies` entry in the package.json.
92    /// The key is the package name.
93    pub dev_dependencies: HashMap<String, Dependency>,
94}
95
96#[derive(Debug, serde::Deserialize)]
97/// A dependency represents a resolved dependency for a Importer (workspace package)
98pub struct Dependency {
99    /// The specifier from the package.json, e.g. "^1.2.3", "workspace:^", etc.
100    pub specifier: String,
101
102    /// The resolved version of the dependency.
103    ///
104    /// This will be either a qualified version that together with the package name forms a key
105    /// into the snapshots map, or a "link:" for workspace packages, e.g.:
106    ///
107    /// ```yaml
108    /// ...
109    /// importers:
110    ///   packages/foo:
111    ///     dependencies:
112    ///       bar:
113    ///         specifier: workspace:^
114    ///         version: link:../bar
115    ///       baz:
116    ///         specifier: ^1.2.0
117    ///         version: 1.2.3(peer@4.5.6)
118    ///   packages/bar:
119    ///     dependencies:
120    ///       baz:
121    ///         specifier: ^1.2.0
122    ///         version: 1.2.3(peer@7.8.9)
123    /// ...
124    /// ```
125    pub version: String,
126}
127
128#[derive(Debug, serde::Deserialize)]
129#[serde(rename_all = "camelCase")]
130/// A snapshot represents a package in the store.
131pub struct Snapshot {
132    #[serde(default)]
133    /// If the package is only used in optional dependencies.
134    pub optional: bool,
135
136    #[serde(default)]
137    /// The resolved dependencies of the package, a map from package name to qualified version.
138    /// ```yaml
139    /// ...
140    /// snapshots:
141    ///   foo@1.2.3:
142    ///     dependencies:
143    ///       bar: 4.5.6
144    ///   bar@4.5.6: {}
145    /// ...
146    /// ```
147    pub dependencies: HashMap<String, String>,
148
149    #[serde(default)]
150    /// As with `dependencies`, but for optional dependencies (including optional peer
151    /// dependencies).
152    pub optional_dependencies: HashMap<String, String>,
153
154    #[serde(default)]
155    /// The package names of peer dependencies of the transitive package dependencies,
156    /// excluding direct peer dependencies.
157    pub transitive_peer_dependencies: Vec<String>,
158}
159
160/// Performs the `pnpm tree {name}` CLI command, printing a user-friendly inverse dependency tree
161/// to stdout of the specified package name for the pnpm workspace in the current directory.
162///
163/// The output format is not specified and may change without a breaking change.
164///
165/// # Errors
166/// - [`Error::ReadLockfile`] If the pnpm-lock.yaml file cannot be read.
167/// - [`Error::ParseLockfile`] If the pnpm-lock.yaml file cannot be parsed.
168/// - [`Error::UnexpectedLockfileContent`] If the lockfile content could not otherwise be
169///   understood.
170pub fn print_tree(workspace_dir: &Path, name: &str) -> Result<()> {
171    let lockfile = Lockfile::read_from_workspace_dir(workspace_dir)?;
172
173    let graph = DependencyGraph::from_lockfile(&lockfile, workspace_dir)?;
174
175    // Print the tree, skipping repeated nodes.
176    let mut seen = HashSet::<NodeId>::new();
177
178    fn print_tree_inner(
179        inverse_deps: &DependencyGraph,
180        seen: &mut HashSet<NodeId>,
181        node_id: &NodeId,
182        depth: usize,
183    ) {
184        if !seen.insert(node_id.clone()) {
185            println!("{:indent$}{node_id} (*)", "", indent = depth * 2,);
186            return;
187        }
188        let Some(dep_ids) = inverse_deps.inverse.get(node_id) else {
189            println!("{:indent$}{node_id}", "", indent = depth * 2,);
190            return;
191        };
192        println!("{:indent$}{node_id}:", "", indent = depth * 2,);
193        for dep_id in dep_ids {
194            print_tree_inner(inverse_deps, seen, dep_id, depth + 1);
195        }
196    }
197
198    for node_id in graph.inverse.keys() {
199        if matches!(node_id, NodeId::Package { name: package_name, .. } if name == package_name) {
200            print_tree_inner(&graph, &mut seen, node_id, 0);
201        }
202    }
203
204    Ok(())
205}
206
207#[derive(Default)]
208/// A dependency graph.
209pub struct DependencyGraph {
210    /// A map from a node to a set of nodes it depends on.
211    pub forward: HashMap<NodeId, HashSet<NodeId>>,
212
213    /// A map from a node to a set of nodes that depend on it.
214    pub inverse: HashMap<NodeId, HashSet<NodeId>>,
215}
216
217impl DependencyGraph {
218    /// Construct a [`DependencyGraph`] from a [`Lockfile`].
219    ///
220    /// Computes a forwards and inverse dependency graph from the lockfile, used to print
221    /// and filter the dependency tree.
222    ///
223    /// # Errors
224    /// - [`Error::UnexpectedLockfileContent`] If the lockfile content could not be understood.
225    pub fn from_lockfile(lockfile: &Lockfile, workspace_dir: &Path) -> Result<Self> {
226        let Lockfile::V9 {
227            importers,
228            snapshots,
229        } = lockfile;
230
231        let mut forward = HashMap::<NodeId, HashSet<NodeId>>::new();
232        let mut inverse = HashMap::<NodeId, HashSet<NodeId>>::new();
233
234        for (path, entry) in importers {
235            let path = workspace_dir.join(path);
236            let node_id = NodeId::Importer { path: path.clone() };
237            for (dep_name, dep) in entry
238                .dependencies
239                .iter()
240                .chain(entry.dev_dependencies.iter())
241            {
242                let dep_id = if let Some(link_path) = dep.version.strip_prefix("link:") {
243                    NodeId::Importer {
244                        path: path.join(link_path),
245                    }
246                } else {
247                    NodeId::Package {
248                        name: dep_name.clone(),
249                        version: dep.version.clone(),
250                    }
251                };
252                forward
253                    .entry(node_id.clone())
254                    .or_default()
255                    .insert(dep_id.clone());
256                inverse.entry(dep_id).or_default().insert(node_id.clone());
257            }
258        }
259
260        for (id, entry) in snapshots {
261            let split = 1 + id[1..].find('@').ok_or(Error::UnexpectedLockfileContent)?;
262            let node_id = NodeId::Package {
263                name: id[..split].to_string(),
264                version: id[split + 1..].to_string(),
265            };
266            for (dep_name, dep_version) in &entry.dependencies {
267                let dep_id = NodeId::Package {
268                    name: dep_name.clone(),
269                    version: dep_version.clone(),
270                };
271                forward
272                    .entry(node_id.clone())
273                    .or_default()
274                    .insert(dep_id.clone());
275                inverse.entry(dep_id).or_default().insert(node_id.clone());
276            }
277        }
278
279        Ok(Self { forward, inverse })
280    }
281}
282
283#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
284/// A node in the dependency graph.
285pub enum NodeId {
286    /// A package in the workspace.
287    Importer {
288        /// The workspace-relative path to the package directory.
289        path: PathBuf,
290    },
291
292    /// A package from the registry.
293    Package {
294        /// The package name.
295        name: String,
296
297        /// The peer-dependency qualified version.
298        version: String,
299    },
300}
301
302impl std::fmt::Display for NodeId {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        match self {
305            NodeId::Importer { path } => write!(f, "{}", path.display()),
306            NodeId::Package { name, version } => write!(f, "{}@{}", name, version),
307        }
308    }
309}