From aa1d99a75b1787250dd4ab081d030b7c75f1e2e5 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Thu, 30 Jul 2026 11:24:23 -0400 Subject: [PATCH 1/4] Expose DOT serialization through the Ruby graph API --- ext/rubydex/graph.c | 37 +++++++++++++++++++++++++++++++ rust/rubydex-sys/src/graph_api.rs | 13 +++++++++++ test/graph_test.rb | 27 ++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/ext/rubydex/graph.c b/ext/rubydex/graph.c index 17b908638..5a08d8b0c 100644 --- a/ext/rubydex/graph.c +++ b/ext/rubydex/graph.c @@ -20,6 +20,7 @@ static VALUE cKeywordParameter; // Interned once in `rdxi_initialize_graph` to avoid repeated symbol-table lookups on hot completion paths. static ID id_self_receiver; +static ID id_show_builtins; // Extracts the required `self_receiver:` kwarg from `opts`. Returns NULL when the value is `nil`, // which means "no self-type to walk" (e.g., empty class body where the singleton class hasn't @@ -870,6 +871,40 @@ static VALUE rdxr_graph_workspace_path(VALUE self) { return path; } +/* + * call-seq: + * to_dot(show_builtins: false) -> String + * + * Returns a Graphviz DOT visualization of the graph. Built-in declarations are omitted by default. + */ +static VALUE rdxr_graph_to_dot(int argc, VALUE *argv, VALUE self) { + VALUE opts; + rb_scan_args(argc, argv, "0:", &opts); + + VALUE show_builtins = Qfalse; + if (!NIL_P(opts)) { + VALUE keyword; + rb_get_kwargs(opts, &id_show_builtins, 0, 1, &keyword); + if (keyword != Qundef) { + show_builtins = keyword; + } + } + + if (show_builtins != Qtrue && show_builtins != Qfalse) { + rb_raise(rb_eArgError, "show_builtins must be true or false"); + } + + void *graph; + TypedData_Get_Struct(self, void *, &graph_type, graph); + + const char *dot = rdx_graph_to_dot(graph, RTEST(show_builtins)); + if (dot == NULL) { + rb_raise(rb_eRuntimeError, "Converting DOT output to Ruby string failed"); + } + + return rdxi_owned_c_string_to_ruby(dot); +} + /* * call-seq: * workspace_path=(path) -> void @@ -950,6 +985,7 @@ void rdxi_initialize_graph(VALUE moduleRubydex) { cKeywordParameter = rb_define_class_under(mRubydex, "KeywordParameter", rb_cObject); id_self_receiver = rb_intern("self_receiver"); + id_show_builtins = rb_intern("show_builtins"); rb_define_alloc_func(cGraph, rdxr_graph_alloc); rb_define_method(cGraph, "initialize_copy", rdxr_graph_initialize_copy, 1); @@ -966,6 +1002,7 @@ void rdxi_initialize_graph(VALUE moduleRubydex) { rb_define_method(cGraph, "method_references", rdxr_graph_method_references, 0); rb_define_method(cGraph, "diagnostics", rdxr_graph_diagnostics, 0); rb_define_method(cGraph, "check_integrity", rdxr_graph_check_integrity, 0); + rb_define_method(cGraph, "to_dot", rdxr_graph_to_dot, -1); rb_define_method(cGraph, "[]", rdxr_graph_aref, 1); rb_define_method(cGraph, "search", rdxr_graph_search, -1); rb_define_method(cGraph, "fuzzy_search", rdxr_graph_fuzzy_search, -1); diff --git a/rust/rubydex-sys/src/graph_api.rs b/rust/rubydex-sys/src/graph_api.rs index aa0cc459a..3ab68400f 100644 --- a/rust/rubydex-sys/src/graph_api.rs +++ b/rust/rubydex-sys/src/graph_api.rs @@ -8,6 +8,7 @@ use crate::document_api::DocumentsIter; use crate::reference_api::{CConstantReference, CMethodReference, ConstantReferencesIter, MethodReferencesIter}; use crate::{name_api, utils}; use libc::{c_char, c_void}; +use rubydex::dot::DotBuilder; use rubydex::errors::Errors; use rubydex::indexing::LanguageId; use rubydex::model::encoding::Encoding; @@ -40,6 +41,18 @@ pub extern "C" fn rdx_graph_free(pointer: GraphPointer) { } } +/// Returns a DOT visualization of the graph. Caller must free the returned pointer with `free_c_string`. +/// +/// # Safety +/// +/// - `pointer` must be a valid `GraphPointer` previously returned by this crate. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rdx_graph_to_dot(pointer: GraphPointer, show_builtins: bool) -> *const c_char { + with_graph(pointer, |graph| { + utils::cstring_raw(&DotBuilder::generate(graph, show_builtins)) + }) +} + /// Runs `action` against the graph referenced by `pointer` under a read lock. /// /// # Panics diff --git a/test/graph_test.rb b/test/graph_test.rb index 7da7ec223..ee44fc816 100644 --- a/test/graph_test.rb +++ b/test/graph_test.rb @@ -1761,6 +1761,33 @@ def test_render_raises_on_invalid_format end end + def test_to_dot_renders_graph_and_hides_built_ins_by_default + with_context do |context| + context.write!("simple.rb", "class SimpleClass; end") + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + dot = graph.to_dot + + assert_includes(dot, "digraph rubydex") + assert_includes(dot, "SimpleClass") + assert_includes(dot, "defines") + assert_includes(dot, "declares") + refute_includes(dot, "rubydex:built-in") + assert_includes(graph.to_dot(show_builtins: true), "rubydex:built-in") + end + end + + def test_to_dot_requires_boolean_show_builtins + graph = Rubydex::Graph.new + + error = assert_raises(ArgumentError) { graph.to_dot(show_builtins: nil) } + assert_includes(error.message, "show_builtins must be true or false") + assert_raises(ArgumentError) { graph.to_dot(unknown: true) } + end + def test_frozen_graph_allows_mutating_methods with_context do |context| context.write!("file.rb", "class Foo; end") From f1e7d2ba9f2bf0e844b7046468e931f20f35fbbd Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Thu, 30 Jul 2026 11:24:28 -0400 Subject: [PATCH 2/4] Extract a shared rdx integration test helper --- test/helpers/executable.rb | 27 +++++++++++++++++++++++++++ test/integration/mcp_server_test.rb | 15 ++------------- 2 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 test/helpers/executable.rb diff --git a/test/helpers/executable.rb b/test/helpers/executable.rb new file mode 100644 index 000000000..2803a7681 --- /dev/null +++ b/test/helpers/executable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "open3" +require "rbconfig" + +module Test + module Helpers + module WithExecutable + private + + #: (*String) -> [String, String, Process::Status] + def run_executable(*arguments) + Open3.capture3( + RbConfig.ruby, + "-rbundler/setup", + executable_path, + *arguments, + ) + end + + #: -> String + def executable_path + File.expand_path("../../exe/rdx", __dir__) + end + end + end +end diff --git a/test/integration/mcp_server_test.rb b/test/integration/mcp_server_test.rb index a15e21dd1..d6a388ce8 100644 --- a/test/integration/mcp_server_test.rb +++ b/test/integration/mcp_server_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "helpers/context" +require "helpers/executable" require "json" require "open3" require "rbconfig" @@ -10,6 +11,7 @@ class MCPServerIntegrationTest < Minitest::Test include Test::Helpers::WithContext + include Test::Helpers::WithExecutable MAX_INDEXING_RETRIES = 200 @@ -106,19 +108,6 @@ def test_mcp_server_e2e private - def run_executable(*arguments) - Open3.capture3( - RbConfig.ruby, - "-rbundler/setup", - executable_path, - *arguments, - ) - end - - def executable_path - File.expand_path("../../exe/rdx", __dir__) - end - def send_message(stdin, message) stdin.puts(JSON.generate(message)) stdin.flush From e735f9dc95bca11702d6735710791546f6fb0c1a Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Thu, 30 Jul 2026 11:24:34 -0400 Subject: [PATCH 3/4] Move DOT visualization to the Ruby CLI --- exe/rdx | 31 ++++++++++++++++++++++++++++--- rust/rubydex/src/main.rs | 21 +++++---------------- rust/rubydex/tests/cli.rs | 23 ----------------------- test/integration/dot_cli_test.rb | 30 ++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 42 deletions(-) create mode 100644 test/integration/dot_cli_test.rb diff --git a/exe/rdx b/exe/rdx index d59a8bf7b..6a771c6d8 100755 --- a/exe/rdx +++ b/exe/rdx @@ -13,6 +13,7 @@ USAGE = <<~TEXT Use `query --schema` to describe the queryable schema (labels, relationships, properties) without indexing the workspace. console Open an interactive session with a populated graph for the current workspace + dot [PATH] Output a Graphviz DOT visualization (workspace defaults to the current dir) mcp [PATH] Run the MCP server for AI assistants (workspace defaults to the current dir) help Show this help message @@ -55,8 +56,8 @@ def with_timer(io, message) end # Builds the workspace graph, sending progress messages to `progress_io`. -def build_graph(progress_io) - graph = Rubydex::Graph.new +def build_graph(progress_io, workspace_path) + graph = Rubydex::Graph.new(workspace_path: File.expand_path(workspace_path)) graph.load_config with_timer(progress_io, "Indexing workspace...") { graph.index_workspace } with_timer(progress_io, "Resolving graph...") { graph.resolve } @@ -68,6 +69,8 @@ require "rubydex" # Resolve the command into an operation on a populated graph. Each command parses its own options # and does any graph-independent work here; a command that needs no graph (like `query --schema`) # handles itself and exits. Whatever falls through returns a lambda that runs against the graph. +workspace_path = Dir.pwd + operation = case command when "query" @@ -106,6 +109,28 @@ operation = rescue ArgumentError => e abort(e.message) end + when "dot" + show_builtins = false + parser = OptionParser.new do |p| + p.banner = "Usage: rdx dot [PATH] [options]" + p.on("--show-builtins", "Include built-in declarations in DOT output") { show_builtins = true } + p.on("-h", "--help", "Show this help") do + puts p + exit + end + end + begin + parser.parse! + rescue OptionParser::ParseError => e + abort_with_usage(e.message) + end + + workspace_path = ARGV.shift || Dir.pwd + abort_with_usage("unexpected argument: #{ARGV.first}") unless ARGV.empty? + + lambda do |graph| + print(graph.to_dot(show_builtins: show_builtins)) + end when "console" OptionParser.new do |parser| parser.banner = "Usage: rdx console" @@ -151,5 +176,5 @@ operation = # Everything that reaches here operates on a populated graph. Progress goes to stderr so stdout # carries only the command's output (e.g. for piping a query's JSON). -graph = build_graph($stderr) +graph = build_graph($stderr, workspace_path) operation.call(graph) diff --git a/rust/rubydex/src/main.rs b/rust/rubydex/src/main.rs index 1afb6a4f6..2ce1f9ac8 100644 --- a/rust/rubydex/src/main.rs +++ b/rust/rubydex/src/main.rs @@ -2,7 +2,6 @@ use clap::{Parser, ValueEnum}; use std::{fs, mem, path::PathBuf}; use rubydex::{ - dot, indexing::{self, IndexerBackend}, integrity, listing, model::graph::Graph, @@ -27,12 +26,6 @@ struct Args { #[arg(long = "stop-after", help = "Stop after the given stage")] stop_after: Option, - #[arg(long = "dot", help = "Output a DOT graph visualization")] - dot: bool, - - #[arg(long = "show-builtins", help = "Include built-in declarations in DOT output")] - show_builtins: bool, - #[arg(long = "stats", help = "Show detailed performance statistics")] stats: bool, @@ -194,15 +187,11 @@ fn main() { } } - // Generate visualization or print statistics - if args.dot { - println!("{}", dot::DotBuilder::generate(&graph, args.show_builtins)); - } else { - println!("Indexed {} files", graph.documents().len()); - println!("Found {} names", graph.declarations().len()); - println!("Found {} definitions", graph.definitions().len()); - println!("Found {} URIs", graph.documents().len()); - } + // Print indexing metrics + println!("Indexed {} files", graph.documents().len()); + println!("Found {} names", graph.declarations().len()); + println!("Found {} definitions", graph.definitions().len()); + println!("Found {} URIs", graph.documents().len()); // Forget the graph so we don't have to wait for deallocation and let the system reclaim the memory at exit mem::forget(graph); diff --git a/rust/rubydex/tests/cli.rs b/rust/rubydex/tests/cli.rs index 94d12ae8e..1a5a55bc4 100644 --- a/rust/rubydex/tests/cli.rs +++ b/rust/rubydex/tests/cli.rs @@ -23,7 +23,6 @@ fn prints_help() { "If the first path is a directory, it is used as the workspace root for rubydex.toml", )) .stdout(predicate::str::contains("--stats")) - .stdout(predicate::str::contains("--dot")) .stdout(predicate::str::contains("--stop-after")); } @@ -133,28 +132,6 @@ fn prints_index_metrics() { }); } -#[test] -fn dot_flag() { - with_context(|context| { - context.write("simple.rb", "class SimpleClass\nend\n"); - - rdx(&[context.absolute_path().to_str().unwrap(), "--dot"]) - .success() - .stdout(predicate::str::contains("digraph rubydex")) - // Document node - .stdout(predicate::str::contains("Document")) - .stdout(predicate::str::contains("simple.rb")) - // Definition node - .stdout(predicate::str::contains("ClassDef")) - .stdout(predicate::str::contains("SimpleClass")) - // Declaration node - .stdout(predicate::str::contains("ClassDecl")) - // Edges - .stdout(predicate::str::contains("defines")) - .stdout(predicate::str::contains("declares")); - }); -} - #[test] fn stop_after() { with_context(|context| { diff --git a/test/integration/dot_cli_test.rb b/test/integration/dot_cli_test.rb new file mode 100644 index 000000000..a81522304 --- /dev/null +++ b/test/integration/dot_cli_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" +require "helpers/context" +require "helpers/executable" + +class DotCLIIntegrationTest < Minitest::Test + include Test::Helpers::WithContext + include Test::Helpers::WithExecutable + + def test_executable_outputs_graphviz + with_context do |context| + context.write!("simple.rb", "class SimpleClass; end") + + stdout, stderr, status = run_executable("dot", context.absolute_path) + + assert_predicate(status, :success?, stderr) + assert_includes(stderr, "Indexing workspace") + assert_includes(stderr, "Resolving graph") + assert_includes(stdout, "digraph rubydex") + assert_includes(stdout, "SimpleClass") + refute_includes(stdout, "rubydex:built-in") + + stdout, stderr, status = run_executable("dot", "--show-builtins", context.absolute_path) + + assert_predicate(status, :success?, stderr) + assert_includes(stdout, "rubydex:built-in") + end + end +end From ac3e93d704d343cd07afcce42a21354704fa28d2 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Thu, 30 Jul 2026 11:24:38 -0400 Subject: [PATCH 4/4] Document Ruby DOT visualization --- AGENTS.md | 2 +- README.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 87821e792..997a6a4e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ When necessary, commands can be executed for the Ruby code. - `bundle exec rake test`: runs the Ruby and Rust test suites - `bundle exec rake ruby_test`: runs all automated Ruby tests - `bundle exec ruby -Itest test/specific_test.rb`: runs a specific test file +- `bundle exec rdx dot [PATH]`: generates a DOT visualization from the Ruby CLI ## Rust workspace @@ -84,7 +85,6 @@ When necessary, commands can be executed for the Rust code. - `cargo run -- `: runs the indexer on the specified directory (must use absolute paths or $HOME, not ~) - `cargo run -- --stats`: runs the indexer with detailed performance breakdown - `cargo run -- --stop-after `: stops after the specified stage (Listing, Indexing, or Resolution) -- `cargo run -- --visualize`: generates a DOT visualization of the graph - `cargo test`: runs Rust tests (all workspace crates) - `cargo test test_name`: runs a specific tests example - `cargo fmt`: auto formats the Rust code diff --git a/README.md b/README.md index 8193a1dcd..eccff4a36 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,32 @@ puts query.render(graph, "json") puts Rubydex::Query.schema("table") ``` +## Visualizing the graph + +`rdx dot` renders the complete resolved graph as Graphviz DOT. It includes documents, +definitions, declarations, nesting, inheritance, and mixin relationships. Built-in +declarations are hidden by default. + +```bash +# Index the current workspace and write its graph +bundle exec rdx dot > graph.dot + +# Index another workspace and include Rubydex's built-in declarations +bundle exec rdx dot --show-builtins path/to/workspace > graph.dot + +# Render the DOT file with Graphviz +dot -Tsvg graph.dot -o graph.svg +``` + +The same output is available from a resolved graph: + +```ruby +File.write("graph.dot", graph.to_dot) +File.write("graph-with-builtins.dot", graph.to_dot(show_builtins: true)) +``` + +DOT is a whole-graph visualization, not a `rdx query --format` option. + ## MCP Server (Experimental) Rubydex can run as an MCP (Model Context Protocol) server, enabling AI assistants