Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -84,7 +85,6 @@ When necessary, commands can be executed for the Rust code.
- `cargo run -- <directory>`: runs the indexer on the specified directory (must use absolute paths or $HOME, not ~)
- `cargo run -- <directory> --stats`: runs the indexer with detailed performance breakdown
- `cargo run -- <directory> --stop-after <stage>`: stops after the specified stage (Listing, Indexing, or Resolution)
- `cargo run -- <directory> --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
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +156 to +157

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're really going to allow the path argument, we should be explicit that it cannot include dependencies.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also find it interesting that we are indexing a workspace using the gemfile of some other workspace. Do we really need the workspace option?


# 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
Expand Down
31 changes: 28 additions & 3 deletions exe/rdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 }
Expand All @@ -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"
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: passing an explicit argument here won't include that workspace's dependencies without setting up the Bundle based on its Gemfile, so this has difference behaviour than 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"
Expand Down Expand Up @@ -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)
37 changes: 37 additions & 0 deletions ext/rubydex/graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions rust/rubydex-sys/src/graph_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
21 changes: 5 additions & 16 deletions rust/rubydex/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use clap::{Parser, ValueEnum};
use std::{fs, mem, path::PathBuf};

use rubydex::{
dot,
Comment thread
Morriar marked this conversation as resolved.
indexing::{self, IndexerBackend},
integrity, listing,
model::graph::Graph,
Expand All @@ -27,12 +26,6 @@ struct Args {
#[arg(long = "stop-after", help = "Stop after the given stage")]
stop_after: Option<StopAfter>,

#[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,

Expand Down Expand Up @@ -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);
Expand Down
23 changes: 0 additions & 23 deletions rust/rubydex/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}

Expand Down Expand Up @@ -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| {
Expand Down
27 changes: 27 additions & 0 deletions test/graph_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions test/helpers/executable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require "open3"
require "rbconfig"

module Test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing behaviour through executables feels weird. I think this is a result of us not having a proper CLI object that can be unit tested yet, which forces us to reach into actually shelling out.

Since we already need to refactor the CLI for the linter, can we avoid adding this? We can add the dot scenarios as unit tests for the CLI as soon as we add it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be streamlined on latest main now, since we have the infrastructure to test rdx invocations through calls to the CLI class.

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
30 changes: 30 additions & 0 deletions test/integration/dot_cli_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading