Add the crate dependency graph visualization to the contributor guide (#3907)

* Add the crate dependency graph visualization to the contributor guide

* Code review fixes

* And more
This commit is contained in:
Keavon Chambers
2026-03-17 01:35:56 -07:00
committed by GitHub
parent df8001fca8
commit d9214c7292
21 changed files with 497 additions and 349 deletions
+2
View File
@@ -59,6 +59,7 @@ fn explore_usage() {
println!("OPTIONS:");
println!("<tool>:");
println!(" bisect Binary search through recent commits to find which introduced a bug or feature");
println!(" deps View the crate dependency graph for the workspace");
println!(" editor View an interactive outline of the editor's message system architecture");
println!();
}
@@ -67,6 +68,7 @@ fn run_task(task: &Task) -> Result<(), Error> {
if let Action::Explore(tool) = &task.action {
match tool.as_deref() {
Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
Some("deps") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
None | Some("--help") => {
explore_usage();
+1 -1
View File
@@ -8,6 +8,6 @@ authors.workspace = true
[dependencies]
serde = { workspace = true }
clap = { workspace = true, features = ["derive"] }
toml = "0.8"
anyhow = { workspace = true }
glob = "0.3"
+121 -271
View File
@@ -1,37 +1,8 @@
use anyhow::{Context, Result, anyhow};
use clap::{Parser, ValueEnum};
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone, ValueEnum)]
enum OutputFormat {
/// Output DOT format (GraphViz)
Dot,
/// Output PNG image (requires GraphViz)
Png,
/// Output SVG image (requires GraphViz)
Svg,
}
#[derive(Parser)]
#[command(name = "crate-hierarchy-viz")]
#[command(about = "Visualize the crate hierarchy in the Graphite workspace")]
struct Args {
/// Workspace root directory (defaults to current directory)
#[arg(short, long)]
workspace: Option<PathBuf>,
/// Output file (defaults to stdout for DOT format, required for PNG/SVG)
#[arg(short, long)]
output: Option<PathBuf>,
/// Output format
#[arg(short, long, value_enum, default_value = "dot")]
format: OutputFormat,
}
#[derive(Debug, Deserialize)]
struct WorkspaceToml {
@@ -41,20 +12,6 @@ struct WorkspaceToml {
#[derive(Debug, Deserialize)]
struct WorkspaceConfig {
members: Vec<String>,
dependencies: Option<HashMap<String, WorkspaceDependency>>,
}
/// Represents a workspace-level dependency in Cargo.toml
/// The Simple variant's String is needed for serde deserialization but never read directly
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(dead_code)]
enum WorkspaceDependency {
Simple(String),
Detailed {
#[serde(flatten)]
_other: HashMap<String, toml::Value>,
},
}
#[derive(Debug, Deserialize)]
@@ -77,18 +34,15 @@ enum CrateDependency {
Simple(String),
Detailed {
path: Option<String>,
workspace: Option<bool>,
#[serde(flatten)]
other: HashMap<String, toml::Value>,
},
}
#[derive(Debug, Clone, PartialEq)]
struct CrateInfo {
name: String,
path: PathBuf,
dependencies: Vec<String>,
external_dependencies: Vec<String>,
}
/// Remove transitive dependencies from the crate list.
@@ -121,7 +75,6 @@ fn collect_all_dependencies(crate_name: &str, dep_map: &HashMap<String, HashSet<
if !visited.insert(crate_name.to_string()) {
return; // Already visited, avoid cycles
}
if let Some(deps) = dep_map.get(crate_name) {
for dep in deps {
collect_all_dependencies(dep, dep_map, visited);
@@ -130,24 +83,50 @@ fn collect_all_dependencies(crate_name: &str, dep_map: &HashMap<String, HashSet<
}
fn main() -> Result<()> {
let args = Args::parse();
let output_path = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("Usage: crate-hierarchy-viz <output-file>"))?;
let workspace_root = args.workspace.unwrap_or_else(|| std::env::current_dir().unwrap());
let workspace_root = std::env::current_dir().unwrap();
let workspace_toml_path = workspace_root.join("Cargo.toml");
// Parse workspace Cargo.toml
let workspace_content = fs::read_to_string(&workspace_toml_path).with_context(|| format!("Failed to read {:?}", workspace_toml_path))?;
let workspace_toml: WorkspaceToml = toml::from_str(&workspace_content).with_context(|| "Failed to parse workspace Cargo.toml")?;
// Get workspace dependencies (external crates defined at workspace level)
let workspace_deps: HashSet<String> = workspace_toml.workspace.dependencies.unwrap_or_default().keys().cloned().collect();
// Parse each member crate and build name mapping
let mut crates = Vec::new();
let mut workspace_crate_names = HashSet::new();
// First pass: collect all workspace crate names
// Expand glob patterns in workspace members (e.g., "node-graph/libraries/*")
let mut resolved_members = Vec::new();
let mut seen_members = HashSet::new();
let abs_root = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.clone());
for member in &workspace_toml.workspace.members {
if member.contains('*') {
let pattern = abs_root.join(member).to_string_lossy().to_string();
let matched: Vec<_> = glob::glob(&pattern)
.with_context(|| format!("Failed to expand glob pattern: {member}"))?
.filter_map(|entry| entry.ok())
.filter_map(|path| path.strip_prefix(&abs_root).ok().map(|p| p.to_string_lossy().to_string()))
.collect();
if matched.is_empty() {
eprintln!("Warning: No matches for glob pattern: {member}");
}
for m in matched {
let normalized = m.replace('\\', "/");
if seen_members.insert(normalized.clone()) {
resolved_members.push(normalized);
}
}
} else {
let normalized = member.replace('\\', "/");
if seen_members.insert(normalized.clone()) {
resolved_members.push(normalized);
}
}
}
// Parse each member crate's Cargo.toml
let mut parsed_crates = Vec::new();
for member in &resolved_members {
let crate_path = workspace_root.join(member);
let cargo_toml_path = crate_path.join("Cargo.toml");
@@ -159,243 +138,114 @@ fn main() -> Result<()> {
let crate_content = fs::read_to_string(&cargo_toml_path).with_context(|| format!("Failed to read {:?}", cargo_toml_path))?;
let crate_toml: CrateToml = toml::from_str(&crate_content).with_context(|| format!("Failed to parse Cargo.toml for {}", member))?;
workspace_crate_names.insert(crate_toml.package.name.clone());
parsed_crates.push((crate_path, crate_toml));
}
// Second pass: parse dependencies now that we know all workspace crate names
for member in &workspace_toml.workspace.members {
let crate_path = workspace_root.join(member);
let cargo_toml_path = crate_path.join("Cargo.toml");
// Collect all workspace crate names
let workspace_crate_names: HashSet<String> = parsed_crates.iter().map(|(_, toml)| toml.package.name.clone()).collect();
if !cargo_toml_path.exists() {
continue;
}
// Build dependency graph, keeping only workspace-internal dependencies
let mut crates: Vec<CrateInfo> = parsed_crates
.into_iter()
.map(|(path, crate_toml)| {
let dependencies = crate_toml
.dependencies
.unwrap_or_default()
.into_iter()
.filter_map(|(dep_name, dep_config)| {
// Resolve the actual package name (handles renamed dependencies)
let actual_name = match &dep_config {
CrateDependency::Detailed { other, .. } => other.get("package").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or(dep_name),
CrateDependency::Simple(_) => dep_name,
};
// Only keep dependencies that are workspace crates
workspace_crate_names.contains(&actual_name).then_some(actual_name)
})
.collect();
let crate_content = fs::read_to_string(&cargo_toml_path).with_context(|| format!("Failed to read {:?}", cargo_toml_path))?;
let crate_toml: CrateToml = toml::from_str(&crate_content).with_context(|| format!("Failed to parse Cargo.toml for {}", member))?;
let mut dependencies = Vec::new();
let mut external_dependencies = Vec::new();
if let Some(deps) = &crate_toml.dependencies {
for (dep_name, dep_config) in deps {
let is_workspace_crate = workspace_crate_names.contains(dep_name);
let is_workspace_dep = workspace_deps.contains(dep_name);
let is_local_dep = match dep_config {
CrateDependency::Detailed { workspace: Some(true), .. } => is_workspace_dep,
CrateDependency::Detailed { path: Some(_), .. } => true,
CrateDependency::Simple(_) => is_workspace_dep,
_ => false,
};
// Check if this dependency has a different package name
let actual_dep_name = match dep_config {
CrateDependency::Detailed { other, .. } => {
// Check if there's a "package" field that renames the dependency
if let Some(toml::Value::String(package_name)) = other.get("package") {
package_name.clone()
} else {
dep_name.clone()
}
}
_ => dep_name.clone(),
};
let is_actual_workspace_crate = workspace_crate_names.contains(&actual_dep_name);
if is_workspace_crate || is_actual_workspace_crate || is_local_dep {
dependencies.push(actual_dep_name);
} else {
external_dependencies.push(actual_dep_name);
}
CrateInfo {
name: crate_toml.package.name,
path,
dependencies,
}
}
})
.collect();
crates.push(CrateInfo {
name: crate_toml.package.name.clone(),
path: crate_path,
dependencies,
external_dependencies,
});
}
// Filter dependencies to only include workspace crates
for crate_info in &mut crates {
crate_info.dependencies.retain(|dep| workspace_crate_names.contains(dep));
}
// Remove transitive dependencies
remove_transitive_dependencies(&mut crates);
// Generate DOT format
let dot_content = generate_dot_format(&crates)?;
// Generate DOT format and write to output file
let dot_content = generate_dot(&crates);
// Handle output based on format
match args.format {
OutputFormat::Dot => {
// Write DOT output
if let Some(output_path) = args.output {
fs::write(&output_path, &dot_content).with_context(|| format!("Failed to write to {:?}", output_path))?;
println!("DOT output written to: {:?}", output_path);
} else {
print!("{}", dot_content);
}
}
OutputFormat::Png | OutputFormat::Svg => {
// Require output file for PNG/SVG
let output_path = args.output.ok_or_else(|| anyhow!("Output file (-o/--output) is required for PNG/SVG formats"))?;
// Check if dot command is available
let dot_check = Command::new("dot").arg("-V").output();
if dot_check.is_err() || !dot_check.as_ref().unwrap().status.success() {
return Err(anyhow!(
"GraphViz 'dot' command not found. Please install GraphViz to generate PNG/SVG output.\n\
On Ubuntu/Debian: sudo apt-get install graphviz\n\
On macOS: brew install graphviz\n\
On Windows: Download from https://graphviz.org/download/"
));
}
// Determine the format argument for dot
let format_arg = match args.format {
OutputFormat::Png => "png",
OutputFormat::Svg => "svg",
_ => unreachable!(),
};
// Run dot command to generate the output
let mut dot_process = Command::new("dot")
.arg(format!("-T{}", format_arg))
.arg("-o")
.arg(&output_path)
.stdin(std::process::Stdio::piped())
.spawn()
.with_context(|| "Failed to spawn 'dot' command")?;
// Write DOT content to stdin
use std::io::Write;
if let Some(mut stdin) = dot_process.stdin.take() {
stdin.write_all(dot_content.as_bytes()).with_context(|| "Failed to write DOT content to 'dot' command")?;
// Close stdin to signal EOF
drop(stdin);
}
// Wait for the command to complete
let status = dot_process.wait().with_context(|| "Failed to wait for 'dot' command")?;
if !status.success() {
return Err(anyhow!("'dot' command failed with exit code: {:?}", status.code()));
}
println!("{} output written to: {:?}", format_arg.to_uppercase(), output_path);
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).with_context(|| format!("Failed to create directory {:?}", parent))?;
}
fs::write(&output_path, &dot_content).with_context(|| format!("Failed to write to {:?}", output_path))?;
Ok(())
}
fn generate_dot_format(crates: &[CrateInfo]) -> Result<String> {
let mut output = String::new();
output.push_str("digraph CrateHierarchy {\n");
output.push_str(" rankdir=LR;\n");
output.push_str(" node [shape=box, style=\"rounded,filled\", fillcolor=lightblue];\n");
output.push_str(" edge [color=gray];\n\n");
fn generate_dot(crates: &[CrateInfo]) -> String {
let mut out = String::new();
out.push_str("digraph CrateHierarchy {\n");
out.push_str(" rankdir=LR;\n");
out.push_str(" node [shape=box, style=\"rounded,filled\", fillcolor=lightblue];\n");
out.push_str(" edge [color=gray];\n\n");
// Add subgraphs for different categories
output.push_str(" subgraph cluster_core {\n");
output.push_str(" label=\"Core Components\";\n");
output.push_str(" style=filled;\n");
output.push_str(" fillcolor=lightgray;\n");
// Define subgraph clusters
let clusters: &[(&str, &str, &str, Box<dyn Fn(&CrateInfo) -> bool>)] = &[
(
"cluster_core",
"Core Components",
"lightgray",
Box::new(|c| (c.name.starts_with("graphite-") || c.name == "editor" || c.name == "graphene-cli") && !c.name.contains("desktop")),
),
(
"cluster_nodegraph",
"Node Graph System",
"lightyellow",
Box::new(|c| c.name == "graph-craft" || c.name == "interpreted-executor" || c.name == "node-macro" || c.name == "preprocessor" || c.name == "graphene-cli"),
),
(
"cluster_node_libraries",
"Node Graph Libraries",
"lightcyan",
Box::new(|c| c.path.to_string_lossy().replace('\\', "/").contains("node-graph/libraries")),
),
(
"cluster_nodes",
"Nodes",
"lightblue",
Box::new(|c| c.path.to_string_lossy().replace('\\', "/").contains("node-graph/nodes")),
),
(
"cluster_desktop",
"Desktop",
"lightgreen",
Box::new(|c| c.path.to_string_lossy().replace('\\', "/").contains("desktop")),
),
];
let core_crates: Vec<_> = crates
.iter()
.filter(|c| (c.name.starts_with("graphite-") || c.name == "editor" || c.name == "graphene-cli") && !c.name.contains("desktop"))
.collect();
for crate_info in &core_crates {
output.push_str(&format!(" \"{}\";\n", crate_info.name));
for (id, label, color, filter) in clusters {
out.push_str(&format!(" subgraph {id} {{\n"));
out.push_str(&format!(" label=\"{label}\";\n"));
out.push_str(" style=filled;\n");
out.push_str(&format!(" fillcolor={color};\n"));
for c in crates.iter().filter(|c| filter(c)) {
out.push_str(&format!(" \"{}\";\n", c.name));
}
out.push_str(" }\n\n");
}
output.push_str(" }\n\n");
output.push_str(" subgraph cluster_nodegraph {\n");
output.push_str(" label=\"Node Graph System\";\n");
output.push_str(" style=filled;\n");
output.push_str(" fillcolor=lightyellow;\n");
let nodegraph_crates: Vec<_> = crates
.iter()
.filter(|c| c.name == "graph-craft" || c.name == "interpreted-executor" || c.name == "node-macro" || c.name == "preprocessor" || c.name == "graphene-cli")
.collect();
for crate_info in &nodegraph_crates {
output.push_str(&format!(" \"{}\";\n", crate_info.name));
}
output.push_str(" }\n\n");
output.push_str(" subgraph cluster_node_libraries {\n");
output.push_str(" label=\"Node Graph Libraries\";\n");
output.push_str(" style=filled;\n");
output.push_str(" fillcolor=lightcyan;\n");
let node_library_crates: Vec<_> = crates
.iter()
.filter(|c| {
let path_str = c.path.to_string_lossy();
path_str.contains("node-graph/libraries")
})
.collect();
for crate_info in &node_library_crates {
output.push_str(&format!(" \"{}\";\n", crate_info.name));
}
output.push_str(" }\n\n");
output.push_str(" subgraph cluster_nodes {\n");
output.push_str(" label=\"Nodes\";\n");
output.push_str(" style=filled;\n");
output.push_str(" fillcolor=lightblue;\n");
let node_crates: Vec<_> = crates
.iter()
.filter(|c| {
let path_str = c.path.to_string_lossy();
path_str.contains("node-graph/nodes")
})
.collect();
for crate_info in &node_crates {
output.push_str(&format!(" \"{}\";\n", crate_info.name));
}
output.push_str(" }\n\n");
output.push_str(" subgraph cluster_desktop{\n");
output.push_str(" label=\"Desktop\";\n");
output.push_str(" style=filled;\n");
output.push_str(" fillcolor=lightgreen;\n");
let desktop_crates: Vec<_> = crates
.iter()
.filter(|c| {
let path_str = c.path.to_string_lossy();
path_str.contains("desktop")
})
.collect();
for crate_info in &desktop_crates {
output.push_str(&format!(" \"{}\";\n", crate_info.name));
}
output.push_str(" }\n\n");
// Add dependencies as edges
// Add dependency edges
for crate_info in crates {
for dep in &crate_info.dependencies {
if dep == "dyn-any" || dep == "node-macro" {
continue;
}
output.push_str(&format!(" \"{}\" -> \"{}\";\n", crate_info.name, dep));
out.push_str(&format!(" \"{}\" -> \"{}\";\n", crate_info.name, dep));
}
}
output.push_str("}\n");
Ok(output)
out.push_str("}\n");
out
}
+11 -3
View File
@@ -1,11 +1,17 @@
use editor::messages::message::Message;
use editor::utility_types::DebugMessageTree;
use std::io::Write;
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let output_path = std::env::args_os().nth(1).map(PathBuf::from).ok_or("Usage: editor-message-tree <output-file>")?;
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
fn main() {
let result = Message::message_tree();
std::fs::create_dir_all("../../website/generated").unwrap();
let mut file = std::fs::File::create("../../website/generated/hierarchical_message_system_tree.txt").unwrap();
let mut file = std::fs::File::create(&output_path).unwrap();
file.write_all(format!("{} `{}#L{}`\n", result.name(), result.path(), result.line_number()).as_bytes()).unwrap();
if let Some(variants) = result.variants() {
for (i, variant) in variants.iter().enumerate() {
@@ -13,6 +19,8 @@ fn main() {
print_tree_node(variant, "", is_last, &mut file);
}
}
Ok(())
}
fn print_tree_node(tree: &DebugMessageTree, prefix: &str, is_last: bool, file: &mut std::fs::File) {
+11 -3
View File
@@ -7,7 +7,13 @@ use crate::utility::*;
use convert_case::{Case, Casing};
use std::collections::HashMap;
fn main() {
fn main() -> Result<(), Box<dyn std::error::Error>> {
let output_path = std::env::args_os()
.nth(1)
.ok_or("Usage: node-docs <output-directory>")?
.into_string()
.map_err(|_| "Output path is not valid UTF-8")?;
// TODO: Also obtain document nodes, not only proto nodes
let nodes = graphene_std::registry::NODE_METADATA.lock().unwrap();
@@ -22,7 +28,7 @@ fn main() {
categories.sort();
// Create _index.md for the node catalog page
page_catalog::write_catalog_index_page(&categories);
page_catalog::write_catalog_index_page(&output_path, &categories);
// Create node category pages and individual node pages
for (index, category) in categories.iter().map(|c| if !OMIT_HIDDEN && c.is_empty() { "Hidden" } else { c }).filter(|c| !c.is_empty()).enumerate() {
@@ -32,7 +38,7 @@ fn main() {
// Create _index.md file for category
let category_path_part = sanitize_path(&category.to_case(Case::Kebab));
let category_path = format!("{NODE_CATALOG_PATH}/{category_path_part}");
let category_path = format!("{output_path}/{category_path_part}");
page_category::write_category_index_page(index, category, &nodes, &category_path);
// Create individual node pages
@@ -40,4 +46,6 @@ fn main() {
page_node::write_node_page(index, id, metadata, &category_path);
}
}
Ok(())
}
+5 -5
View File
@@ -3,12 +3,12 @@ use convert_case::{Case, Casing};
use indoc::formatdoc;
use std::io::Write;
pub fn write_catalog_index_page(categories: &[String]) {
if std::path::Path::new(NODE_CATALOG_PATH).exists() {
std::fs::remove_dir_all(NODE_CATALOG_PATH).expect("Failed to remove existing node catalog directory");
pub fn write_catalog_index_page(output_path: &str, categories: &[String]) {
if std::path::Path::new(output_path).exists() {
std::fs::remove_dir_all(output_path).expect("Failed to remove existing node catalog directory");
}
std::fs::create_dir_all(NODE_CATALOG_PATH).expect("Failed to create node catalog directory");
let page_path = format!("{NODE_CATALOG_PATH}/_index.md");
std::fs::create_dir_all(output_path).expect("Failed to create node catalog directory");
let page_path = format!("{output_path}/_index.md");
let mut page = std::fs::File::create(&page_path).expect("Failed to create index file");
write_frontmatter(&mut page);
-1
View File
@@ -1,7 +1,6 @@
use graph_craft::proto::NodeMetadata;
use indoc::indoc;
pub const NODE_CATALOG_PATH: &str = "../../website/content/learn/node-catalog";
pub const OMIT_HIDDEN: bool = true;
pub fn category_description(category: &str) -> &str {