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
118 changes: 94 additions & 24 deletions exe/rdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,108 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))

require "optparse"

options = {}
USAGE = <<~TEXT
Usage: rdx <command> [options]

OptionParser.new do |parser|
parser.on("--version", "Print the gem's version") do
require "rubydex/version"
puts "v#{Rubydex::VERSION}"
exit
end
Commands:
query <CYPHER> Run a Cypher query against the workspace graph and print the result
schema Describe the queryable Cypher schema (labels, relationships, properties)
console Open an interactive session with a populated graph for the current workspace
help Show this help message

parser.on("-h", "--help", "Prints this help") do
puts parser
exit
end
Run `rdx <command> --help` for command-specific options.
TEXT

parser.on("-i", "--interactive", "Open an interactive session with a populated graph for the current workspace") do
options[:interactive] = true
end
end.parse!
def abort_with_usage(message)
warn(message)
warn("")
warn(USAGE)
exit(1)
end

require "rubydex"
# Top-level --version / --help / bare invocation, handled before command dispatch.
case ARGV.first
when "--version", "version"
require "rubydex/version"
puts "v#{Rubydex::VERSION}"
exit
when nil, "-h", "--help", "help"
puts USAGE
exit
end

def __with_timer(message, &block)
print(message)
command = ARGV.shift

def with_timer(io, message)
io.print(message)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
block.call
yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
puts " finished in #{duration.round(2)}ms"
io.puts(" finished in #{duration.round(2)}ms")
end

# Builds the workspace graph, sending progress messages to `progress_io`.
def build_graph(progress_io)

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.

Other than quick one off switches, like --version, I would assume everything in this executable always depends on a populated graph (like interactive mode or query).

What do you think of keeping the one off switches as early returns at the top, then we populate the graph and the different commands simply perform different operations on it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Generally, that is correct, except for the schema subcommand handling, which returns a rendering of the schema (with documentation) without needing to index anything.

I could turn schema subcommand into a --schema flag for the query subcommand, if you want, and that would then become special handling for that subcommand, and we can do what you are suggesting.

Let me know what you prefer.

graph = Rubydex::Graph.new
with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
with_timer(progress_io, "Resolving graph...") { graph.resolve }
graph
end

# Parses `--format`/`--help` for a command and returns the chosen format.
def parse_format(usage)
format = "table"
OptionParser.new do |parser|
parser.banner = usage
parser.on("--format FORMAT", ["table", "json"], "Output format (table or json)") { |value| format = value }
parser.on("-h", "--help", "Show this help") do
puts parser
exit
end
end.parse!
format
end

graph = Rubydex::Graph.new
__with_timer("Indexing workspace...") { graph.index_workspace }
__with_timer("Resolving graph...") { graph.resolve }
case command
when "query"
format = parse_format("Usage: rdx query <CYPHER> [options]")
query = ARGV.shift
abort_with_usage("`query` requires a Cypher query argument") if query.nil? || query.empty?

require "rubydex"

# Parse the query first so a malformed query fails fast, before the expensive workspace indexing.
parsed = begin
Rubydex::Query.parse(query)
rescue ArgumentError => e
abort(e.message)
end

# Progress goes to stderr so stdout carries only the query result (e.g. for piping JSON).
graph = build_graph($stderr)
begin
print(parsed.render(graph, format))
rescue ArgumentError => e
abort(e.message)
end
when "schema"
format = parse_format("Usage: rdx schema [options]")

require "rubydex"
# The schema is static, so describe it without indexing the workspace.
print(Rubydex::Query.schema(format))
when "console"
OptionParser.new do |parser|
parser.banner = "Usage: rdx console"
parser.on("-h", "--help", "Show this help") do
puts parser
exit
end
end.parse!

require "rubydex"
graph = build_graph($stdout)

if options[:interactive]
begin
require "irb"
IRB.setup(nil)
Expand All @@ -48,4 +116,6 @@ if options[:interactive]
rescue LoadError
abort("Interactive mode requires `irb` to be in the bundle")
end
else
abort_with_usage("unknown command: #{command}")
end
103 changes: 103 additions & 0 deletions ext/rubydex/graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@
#include "utils.h"

static VALUE cGraph;
static VALUE cQuery;
static VALUE mRubydex;
static VALUE cKeyword;
static VALUE cKeywordParameter;

// Interned once in `rdxi_initialize_graph` to avoid repeated symbol-table lookups on hot completion paths.
static ID id_self_receiver;

// Coerces an optional format argument (String, Symbol, or nil) to a C string; defined below.
static const char *cypher_format_cstr(VALUE format);

// 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
// been created). Raises ArgumentError if the kwarg is absent, of the wrong type, or an empty
Expand Down Expand Up @@ -750,6 +754,99 @@ static VALUE rdxr_graph_keyword(VALUE self, VALUE name) {
return rb_class_new_instance(2, argv, cKeyword);
}

// Rubydex::Query.schema(format = :table) -> String
// Returns a description of the queryable Cypher schema. `format` may be "table" (default) or "json".
// The schema is static, so this is a class method and does not require a graph.
static VALUE rdxr_cypher_schema(int argc, VALUE *argv, VALUE self) {
VALUE format;
rb_scan_args(argc, argv, "01", &format);

const char *output = rdx_cypher_schema(cypher_format_cstr(format));
VALUE result = output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(output);
if (output != NULL) {
free_c_string(output);
}

return result;
}

// Coerces an optional format argument (String, Symbol, or nil) to a C string, defaulting to "table".
static const char *cypher_format_cstr(VALUE format) {
if (NIL_P(format)) {
return "table";
}
if (RB_TYPE_P(format, T_SYMBOL)) {
format = rb_sym2str(format);
}
Check_Type(format, T_STRING);
return StringValueCStr(format);
}

// Free function for Rubydex::Query: releases the parsed query allocated by Rust.
static void query_free(void *ptr) {
if (ptr) {
rdx_cypher_query_free(ptr);
}
}

static const rb_data_type_t query_type = {
.wrap_struct_name = "Rubydex::Query",
.function = {
.dmark = NULL,
.dfree = query_free,
.dsize = NULL,
.dcompact = NULL,
},
.parent = NULL,
.data = NULL,
.flags = RUBY_TYPED_FREE_IMMEDIATELY,
};

// Rubydex::Query.parse(query) -> Rubydex::Query
// Parses a Cypher query into an opaque, reusable object, without needing a graph. Raises
// ArgumentError on a syntax error, so callers can validate a query before building a graph.
static VALUE rdxr_query_parse(VALUE klass, VALUE query) {
Check_Type(query, T_STRING);

struct CParseResult result = rdx_cypher_parse(StringValueCStr(query));
if (result.error != NULL) {
VALUE message = rb_utf8_str_new_cstr(result.error);
free_c_string(result.error);
rb_raise(rb_eArgError, "%s", StringValueCStr(message));
}

return TypedData_Wrap_Struct(klass, &query_type, result.query);
}

// Rubydex::Query#render(graph, format = :table) -> String
// Runs this parsed query against the given graph and returns the formatted output. `format` may be
// "table" (default) or "json". Raises ArgumentError on an execution or format error.
static VALUE rdxr_query_render(int argc, VALUE *argv, VALUE self) {
VALUE graph_obj, format;
rb_scan_args(argc, argv, "11", &graph_obj, &format);

void *query;
TypedData_Get_Struct(self, void *, &query_type, query);

void *graph;
TypedData_Get_Struct(graph_obj, void *, &graph_type, graph);

struct CQueryResult result = rdx_query_run(query, graph, cypher_format_cstr(format));

if (result.error != NULL) {
VALUE message = rb_utf8_str_new_cstr(result.error);
free_c_string(result.error);
rb_raise(rb_eArgError, "%s", StringValueCStr(message));
}

VALUE output = result.output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(result.output);
if (result.output != NULL) {
free_c_string(result.output);
}

return output;
}

void rdxi_initialize_graph(VALUE moduleRubydex) {
mRubydex = moduleRubydex;
cGraph = rb_define_class_under(mRubydex, "Graph", rb_cObject);
Expand Down Expand Up @@ -784,4 +881,10 @@ void rdxi_initialize_graph(VALUE moduleRubydex) {
rb_define_method(cGraph, "exclude_paths", rdxr_graph_exclude_paths, 1);
rb_define_method(cGraph, "excluded_paths", rdxr_graph_excluded_paths, 0);
rb_define_method(cGraph, "keyword", rdxr_graph_keyword, 1);

cQuery = rb_define_class_under(mRubydex, "Query", rb_cObject);
rb_undef_alloc_func(cQuery);
rb_define_singleton_method(cQuery, "parse", rdxr_query_parse, 1);
rb_define_singleton_method(cQuery, "schema", rdxr_cypher_schema, -1);
rb_define_method(cQuery, "render", rdxr_query_render, -1);
}
13 changes: 13 additions & 0 deletions rbi/rubydex.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,19 @@ end

class Rubydex::IntegrityFailure < Rubydex::Failure; end

class Rubydex::Query
class << self
sig { params(query: String).returns(Rubydex::Query) }
def parse(query); end

sig { params(format: T.any(String, Symbol)).returns(String) }
def schema(format = :table); end
end

sig { params(graph: Rubydex::Graph, format: T.any(String, Symbol)).returns(String) }
def render(graph, format = :table); end
end

class Rubydex::Graph
IGNORED_DIRECTORIES = T.let(T.unsafe(nil), T::Array[String])

Expand Down
7 changes: 7 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading