diff --git a/Cargo.lock b/Cargo.lock index 1744e2f7c..6a77f9d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6528,6 +6528,7 @@ dependencies = [ "same-file", "schemars", "serde", + "serde_json", "smallvec", "textwrap", "thiserror 2.0.17", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index f643e1e8d..5199008c4 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -6644,6 +6644,10 @@ pub struct DisplayTreeArgs { /// Show compressed wheel sizes for packages in the tree. #[arg(long)] pub show_sizes: bool, + + /// Output the dependency tree as JSON. + #[arg(long)] + pub json: bool, } #[derive(Args, Debug)] diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 158e2967d..99c39a218 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -62,6 +62,7 @@ rustc-hash = { workspace = true } same-file = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true } smallvec = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index 1e8491855..3732d865a 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeSet, VecDeque}; use either::Either; use itertools::Itertools; use owo_colors::OwoColorize; +use serde_json::json; use petgraph::graph::{EdgeIndex, NodeIndex}; use petgraph::prelude::EdgeRef; use petgraph::{Direction, Graph}; @@ -287,6 +288,137 @@ impl TreeFormatter for TextFormatter { } } +/// A JSON tree formatter that produces structured JSON output. +/// +/// This formatter produces output like: +/// ```json +/// { +/// "name": "package-name", +/// "version": "1.0.0", +/// "dependencies": [ +/// { +/// "name": "dependency-1", +/// "version": "2.0.0", +/// "dependencies": [] +/// } +/// ] +/// } +/// ``` +#[derive(Debug)] +struct JsonFormatter { + /// Stack of JSON objects being built. + /// The top of the stack is the current node being processed. + stack: Vec, + /// The root nodes (top-level packages). + roots: Vec, +} + +impl JsonFormatter { + /// Create a new JSON formatter. + fn new() -> Self { + Self { + stack: Vec::new(), + roots: Vec::new(), + } + } +} + +impl TreeFormatter for JsonFormatter { + type Output = serde_json::Value; + + fn begin_tree(&mut self) { + // Nothing to do for JSON output + } + + fn end_tree(&mut self) -> Self::Output { + // Return all roots as a JSON array + json!(self.roots) + } + + fn begin_node(&mut self, info: &NodeInfo, _position: NodePosition) { + // Create a JSON object for this node + let mut node = json!({ + "name": info.package_id.name.to_string(), + }); + + // Add optional fields + if let Some(version) = info.version { + node["version"] = json!(version.to_string()); + } + + if let Some(extras) = info.extras { + if !extras.is_empty() { + node["extras"] = json!(extras.iter().map(|e| e.to_string()).collect::>()); + } + } + + if let Some(edge_type) = info.edge_type { + match edge_type { + EdgeType::Optional(extra) => { + node["extra"] = json!(extra.to_string()); + } + EdgeType::Dev(group) => { + node["group"] = json!(group.to_string()); + } + EdgeType::Prod => {} + } + } + + if let Some(size) = info.size { + node["size"] = json!(size); + } + + if let Some(latest) = info.latest_version { + node["latest"] = json!(latest.to_string()); + } + + // Initialize empty dependencies array + node["dependencies"] = json!([]); + + // Push onto stack + self.stack.push(node); + } + + fn end_node(&mut self) { + // Pop the current node from the stack + let node = self.stack.pop().expect("Stack should not be empty"); + + if self.stack.is_empty() { + // This is a root node - add to roots + self.roots.push(node); + } else { + // This is a child node - add to parent's dependencies + let parent = self.stack.last_mut().expect("Parent should exist"); + parent["dependencies"] + .as_array_mut() + .expect("Dependencies should be an array") + .push(node); + } + } + + fn mark_visited(&mut self) { + // Mark the current node as deduplicated + if let Some(node) = self.stack.last_mut() { + node["deduplicated"] = json!(true); + } + } + + fn mark_cycle(&mut self) { + // Mark the current node as a cycle + if let Some(node) = self.stack.last_mut() { + node["cycle"] = json!(true); + } + } + + fn begin_children(&mut self, _count: usize) { + // Nothing to do for JSON output + } + + fn end_children(&mut self) { + // Nothing to do for JSON output + } +} + #[derive(Debug)] pub struct TreeDisplay<'env> { /// The constructed dependency graph. @@ -899,6 +1031,62 @@ impl<'env> TreeDisplay<'env> { formatter.end_tree() } + + /// Depth-first traverse the nodes to render the tree as JSON. + pub fn render_json(&self) -> serde_json::Value { + let mut path = Vec::new(); + let mut visited = + FxHashMap::with_capacity_and_hasher(self.graph.node_count(), FxBuildHasher); + let mut formatter = JsonFormatter::new(); + + formatter.begin_tree(); + + for node in &self.roots { + match self.graph[*node] { + Node::Root => { + let edges: Vec<_> = self.graph.edges_directed(*node, Direction::Outgoing).collect(); + let total_siblings = edges.len(); + for (index, edge) in edges.into_iter().enumerate() { + let node = edge.target(); + path.clear(); + let position = NodePosition { + depth: 0, + is_first_child: index == 0, + is_last_child: index == total_siblings - 1, + child_index: index, + total_siblings, + }; + self.visit_with_formatter( + Cursor::new(node, edge.id()), + &mut formatter, + &mut visited, + &mut path, + position, + ); + } + } + Node::Package(_) => { + path.clear(); + let position = NodePosition { + depth: 0, + is_first_child: true, + is_last_child: true, + child_index: 0, + total_siblings: 1, + }; + self.visit_with_formatter( + Cursor::root(*node), + &mut formatter, + &mut visited, + &mut path, + position, + ); + } + } + } + + formatter.end_tree() + } } #[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 49d2a25d1..d23e75c7f 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -47,6 +47,7 @@ pub(crate) async fn tree( invert: bool, outdated: bool, show_sizes: bool, + json: bool, python_version: Option, python_platform: Option, python: Option, @@ -283,7 +284,14 @@ pub(crate) async fn tree( show_sizes, ); - print!("{tree}"); + if json { + // Output JSON + let json_output = tree.render_json(); + println!("{}", serde_json::to_string_pretty(&json_output)?); + } else { + // Output text + print!("{tree}"); + } Ok(ExitStatus::Success) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 35b1569d2..4db7fa1f3 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2205,6 +2205,7 @@ async fn run_project( args.invert, args.outdated, args.show_sizes, + args.json, args.python_version, args.python_platform, args.python, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 25d11cab7..5f3480178 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1874,6 +1874,7 @@ pub(crate) struct TreeSettings { pub(crate) invert: bool, pub(crate) outdated: bool, pub(crate) show_sizes: bool, + pub(crate) json: bool, #[allow(dead_code)] pub(crate) script: Option, pub(crate) python_version: Option, @@ -1941,6 +1942,7 @@ impl TreeSettings { invert: tree.invert, outdated: tree.outdated, show_sizes: tree.show_sizes, + json: tree.json, script, python_version, python_platform,