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}