Skip to content

Commit 13f37ec

Browse files
committed
Expose Cypher via Rubydex::Graph and the rdx command CLI
Add FFI exports (rdx_graph_query and rdx_cypher_schema) in rubydex-sys, bind them as the Graph#query instance method and the Graph.cypher_schema class method, and add their Sorbet signatures. query accepts an optional format (String or Symbol, default :table) and raises ArgumentError on parse, execution, or format errors. Restructure the exe/rdx executable around subcommands: `rdx query <CYPHER>`, `rdx schema`, and `rdx console` (the interactive session), each with a --format option where applicable. Cover the Ruby API with tests for query output, schema output, format coercion, label disjunction, and error handling.
1 parent e3c1975 commit 13f37ec

5 files changed

Lines changed: 352 additions & 24 deletions

File tree

exe/rdx

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,100 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
55

66
require "optparse"
77

8-
options = {}
8+
USAGE = <<~TEXT
9+
Usage: rdx <command> [options]
910
10-
OptionParser.new do |parser|
11-
parser.on("--version", "Print the gem's version") do
12-
require "rubydex/version"
13-
puts "v#{Rubydex::VERSION}"
14-
exit
15-
end
11+
Commands:
12+
query <CYPHER> Run a Cypher query against the workspace graph and print the result
13+
schema Describe the queryable Cypher schema (labels, relationships, properties)
14+
console Open an interactive session with a populated graph for the current workspace
15+
help Show this help message
1616
17-
parser.on("-h", "--help", "Prints this help") do
18-
puts parser
19-
exit
20-
end
17+
Run `rdx <command> --help` for command-specific options.
18+
TEXT
2119

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

27-
require "rubydex"
27+
# Top-level --version / --help / bare invocation, handled before command dispatch.
28+
case ARGV.first
29+
when "--version", "version"
30+
require "rubydex/version"
31+
puts "v#{Rubydex::VERSION}"
32+
exit
33+
when nil, "-h", "--help", "help"
34+
puts USAGE
35+
exit
36+
end
37+
38+
command = ARGV.shift
2839

29-
def __with_timer(message, &block)
30-
print(message)
40+
def with_timer(io, message)
41+
io.print(message)
3142
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
32-
block.call
43+
yield
3344
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
34-
puts " finished in #{duration.round(2)}ms"
45+
io.puts(" finished in #{duration.round(2)}ms")
46+
end
47+
48+
# Builds the workspace graph, sending progress messages to `progress_io`.
49+
def build_graph(progress_io)
50+
graph = Rubydex::Graph.new
51+
with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
52+
with_timer(progress_io, "Resolving graph...") { graph.resolve }
53+
graph
3554
end
3655

37-
graph = Rubydex::Graph.new
38-
__with_timer("Indexing workspace...") { graph.index_workspace }
39-
__with_timer("Resolving graph...") { graph.resolve }
56+
# Parses `--format`/`--help` for a command and returns the chosen format.
57+
def parse_format(usage)
58+
format = "table"
59+
OptionParser.new do |parser|
60+
parser.banner = usage
61+
parser.on("--format FORMAT", ["table", "json"], "Output format (table or json)") { |value| format = value }
62+
parser.on("-h", "--help", "Show this help") do
63+
puts parser
64+
exit
65+
end
66+
end.parse!
67+
format
68+
end
69+
70+
case command
71+
when "query"
72+
format = parse_format("Usage: rdx query <CYPHER> [options]")
73+
query = ARGV.shift
74+
abort_with_usage("`query` requires a Cypher query argument") if query.nil? || query.empty?
75+
76+
require "rubydex"
77+
# Progress goes to stderr so stdout carries only the query result (e.g. for piping JSON).
78+
graph = build_graph($stderr)
79+
begin
80+
print(graph.query(query, format))
81+
rescue ArgumentError => e
82+
abort(e.message)
83+
end
84+
when "schema"
85+
format = parse_format("Usage: rdx schema [options]")
86+
87+
require "rubydex"
88+
# The schema is static, so describe it without indexing the workspace.
89+
print(Rubydex::Graph.cypher_schema(format))
90+
when "console"
91+
OptionParser.new do |parser|
92+
parser.banner = "Usage: rdx console"
93+
parser.on("-h", "--help", "Show this help") do
94+
puts parser
95+
exit
96+
end
97+
end.parse!
98+
99+
require "rubydex"
100+
graph = build_graph($stdout)
40101

41-
if options[:interactive]
42102
begin
43103
require "irb"
44104
IRB.setup(nil)
@@ -48,4 +108,6 @@ if options[:interactive]
48108
rescue LoadError
49109
abort("Interactive mode requires `irb` to be in the bundle")
50110
end
111+
else
112+
abort_with_usage("unknown command: #{command}")
51113
end

ext/rubydex/graph.c

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,68 @@ static VALUE rdxr_graph_keyword(VALUE self, VALUE name) {
750750
return rb_class_new_instance(2, argv, cKeyword);
751751
}
752752

753+
// Graph#query: (String query, ?(String | Symbol) format) -> String
754+
// Runs a Cypher query against the graph and returns the formatted output.
755+
// `format` may be "table" (default) or "json". Raises ArgumentError on a parse, execution, or
756+
// format error.
757+
static VALUE rdxr_graph_query(int argc, VALUE *argv, VALUE self) {
758+
VALUE query, format;
759+
rb_scan_args(argc, argv, "11", &query, &format);
760+
Check_Type(query, T_STRING);
761+
762+
const char *format_str = "table";
763+
if (!NIL_P(format)) {
764+
if (RB_TYPE_P(format, T_SYMBOL)) {
765+
format = rb_sym2str(format);
766+
}
767+
Check_Type(format, T_STRING);
768+
format_str = StringValueCStr(format);
769+
}
770+
771+
void *graph;
772+
TypedData_Get_Struct(self, void *, &graph_type, graph);
773+
774+
struct CQueryResult result = rdx_graph_query(graph, StringValueCStr(query), format_str);
775+
776+
if (result.error != NULL) {
777+
VALUE message = rb_utf8_str_new_cstr(result.error);
778+
free_c_string(result.error);
779+
rb_raise(rb_eArgError, "%s", StringValueCStr(message));
780+
}
781+
782+
VALUE output = result.output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(result.output);
783+
if (result.output != NULL) {
784+
free_c_string(result.output);
785+
}
786+
787+
return output;
788+
}
789+
790+
// Rubydex::Graph.cypher_schema(format = :table) -> String
791+
// Returns a description of the queryable Cypher schema. `format` may be "table" (default) or "json".
792+
// The schema is static, so this is a class method and does not require a graph instance.
793+
static VALUE rdxr_cypher_schema(int argc, VALUE *argv, VALUE self) {
794+
VALUE format;
795+
rb_scan_args(argc, argv, "01", &format);
796+
797+
const char *format_str = "table";
798+
if (!NIL_P(format)) {
799+
if (RB_TYPE_P(format, T_SYMBOL)) {
800+
format = rb_sym2str(format);
801+
}
802+
Check_Type(format, T_STRING);
803+
format_str = StringValueCStr(format);
804+
}
805+
806+
const char *output = rdx_cypher_schema(format_str);
807+
VALUE result = output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(output);
808+
if (output != NULL) {
809+
free_c_string(output);
810+
}
811+
812+
return result;
813+
}
814+
753815
void rdxi_initialize_graph(VALUE moduleRubydex) {
754816
mRubydex = moduleRubydex;
755817
cGraph = rb_define_class_under(mRubydex, "Graph", rb_cObject);
@@ -784,4 +846,7 @@ void rdxi_initialize_graph(VALUE moduleRubydex) {
784846
rb_define_method(cGraph, "exclude_paths", rdxr_graph_exclude_paths, 1);
785847
rb_define_method(cGraph, "excluded_paths", rdxr_graph_excluded_paths, 0);
786848
rb_define_method(cGraph, "keyword", rdxr_graph_keyword, 1);
849+
rb_define_method(cGraph, "query", rdxr_graph_query, -1);
850+
851+
rb_define_singleton_method(cGraph, "cypher_schema", rdxr_cypher_schema, -1);
787852
}

rbi/rubydex.rbi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,11 @@ class Rubydex::IntegrityFailure < Rubydex::Failure; end
275275
class Rubydex::Graph
276276
IGNORED_DIRECTORIES = T.let(T.unsafe(nil), T::Array[String])
277277

278+
class << self
279+
sig { params(format: T.any(String, Symbol)).returns(String) }
280+
def cypher_schema(format = :table); end
281+
end
282+
278283
sig { params(workspace_path: T.nilable(String)).void }
279284
def initialize(workspace_path: nil); end
280285

@@ -324,6 +329,9 @@ class Rubydex::Graph
324329
sig { params(require_path: String, load_paths: T::Array[String]).returns(T.nilable(Rubydex::Document)) }
325330
def resolve_require_path(require_path, load_paths); end
326331

332+
sig { params(query: String, format: T.any(String, Symbol)).returns(String) }
333+
def query(query, format = :table); end
334+
327335
sig { params(query: String).returns(T::Enumerable[Rubydex::Declaration]) }
328336
def search(query); end
329337

rust/rubydex-sys/src/graph_api.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use rubydex::model::ids::{DeclarationId, NameId, UriId};
1414
use rubydex::model::keywords;
1515
use rubydex::model::name::NameRef;
1616
use rubydex::model::visibility::Visibility;
17+
use rubydex::query::cypher::{self, OutputFormat};
1718
use rubydex::query::{CompletionCandidate, CompletionContext, CompletionReceiver};
1819
use rubydex::resolution::Resolver;
1920
use rubydex::{indexing, integrity, listing, query};
@@ -977,6 +978,91 @@ pub unsafe extern "C" fn rdx_keyword_get(name: *const c_char) -> *const CKeyword
977978
}
978979
}
979980

981+
/// The result of running a Cypher query, carrying either the formatted output or an error message.
982+
#[repr(C)]
983+
pub struct CQueryResult {
984+
/// Non-null on success; null on error. Caller must free with `free_c_string`.
985+
pub output: *const c_char,
986+
/// Non-null on error; null on success. Caller must free with `free_c_string`.
987+
pub error: *const c_char,
988+
}
989+
990+
impl CQueryResult {
991+
fn success(output: &str) -> Self {
992+
match CString::new(output) {
993+
Ok(c_string) => Self {
994+
output: c_string.into_raw().cast_const(),
995+
error: ptr::null(),
996+
},
997+
Err(_) => Self::error("query output contained an interior NUL byte"),
998+
}
999+
}
1000+
1001+
fn error(message: &str) -> Self {
1002+
Self {
1003+
output: ptr::null(),
1004+
error: CString::new(message).map_or(ptr::null(), |s| s.into_raw().cast_const()),
1005+
}
1006+
}
1007+
}
1008+
1009+
/// Returns a description of the queryable Cypher schema (node labels, relationship types, and
1010+
/// properties) in the given format (`"table"` or `"json"`). The schema is static and requires no
1011+
/// graph. Caller must free the returned pointer with `free_c_string`.
1012+
///
1013+
/// # Safety
1014+
///
1015+
/// - `format` must be a valid, null-terminated UTF-8 string.
1016+
#[unsafe(no_mangle)]
1017+
pub unsafe extern "C" fn rdx_cypher_schema(format: *const c_char) -> *const c_char {
1018+
let format_str = unsafe { utils::convert_char_ptr_to_string(format) }.unwrap_or_else(|_| "table".to_string());
1019+
let output_format = if format_str == "json" {
1020+
OutputFormat::Json
1021+
} else {
1022+
OutputFormat::Table
1023+
};
1024+
1025+
CString::new(cypher::schema(output_format)).map_or(ptr::null(), |s| s.into_raw().cast_const())
1026+
}
1027+
1028+
/// Runs a Cypher query against the graph and returns the formatted output or an error message.
1029+
///
1030+
/// `format` must be `"table"` or `"json"`.
1031+
///
1032+
/// # Safety
1033+
///
1034+
/// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
1035+
/// - `query` and `format` must be valid, null-terminated UTF-8 strings.
1036+
#[unsafe(no_mangle)]
1037+
pub unsafe extern "C" fn rdx_graph_query(
1038+
pointer: GraphPointer,
1039+
query: *const c_char,
1040+
format: *const c_char,
1041+
) -> CQueryResult {
1042+
let Ok(query_str) = (unsafe { utils::convert_char_ptr_to_string(query) }) else {
1043+
return CQueryResult::error("query is not valid UTF-8");
1044+
};
1045+
1046+
let Ok(format_str) = (unsafe { utils::convert_char_ptr_to_string(format) }) else {
1047+
return CQueryResult::error("format is not valid UTF-8");
1048+
};
1049+
1050+
let output_format = match format_str.as_str() {
1051+
"table" => OutputFormat::Table,
1052+
"json" => OutputFormat::Json,
1053+
other => {
1054+
return CQueryResult::error(&format!("unknown query format `{other}` (expected `table` or `json`)"));
1055+
}
1056+
};
1057+
1058+
with_graph(pointer, |graph| {
1059+
match cypher::run_query(graph, &query_str, output_format) {
1060+
Ok(output) => CQueryResult::success(&output),
1061+
Err(error) => CQueryResult::error(&error.to_string()),
1062+
}
1063+
})
1064+
}
1065+
9801066
#[repr(u8)]
9811067
#[derive(Debug, Clone, Copy)]
9821068
pub enum CVisibility {

0 commit comments

Comments
 (0)