rubydex 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -3
  3. data/THIRD_PARTY_LICENSES.html +34 -1
  4. data/exe/rdx +131 -55
  5. data/ext/rubydex/declaration.c +1 -1
  6. data/ext/rubydex/definition.c +32 -4
  7. data/ext/rubydex/graph.c +14 -3
  8. data/ext/rubydex/query.c +105 -0
  9. data/ext/rubydex/query.h +8 -0
  10. data/ext/rubydex/reference.c +60 -0
  11. data/ext/rubydex/rubydex.c +2 -0
  12. data/ext/rubydex/utils.c +12 -0
  13. data/ext/rubydex/utils.h +5 -0
  14. data/lib/rubydex/version.rb +1 -1
  15. data/rbi/rubydex.rbi +22 -0
  16. data/rust/Cargo.lock +7 -0
  17. data/rust/rubydex/Cargo.toml +1 -0
  18. data/rust/rubydex/benches/graph_memory.rs +22 -4
  19. data/rust/rubydex/src/compile_assertions.rs +15 -0
  20. data/rust/rubydex/src/diagnostic.rs +1 -1
  21. data/rust/rubydex/src/indexing/rbs_indexer.rs +14 -2
  22. data/rust/rubydex/src/indexing/ruby_indexer.rs +49 -58
  23. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +72 -16
  24. data/rust/rubydex/src/main.rs +2 -124
  25. data/rust/rubydex/src/model/declaration.rs +0 -11
  26. data/rust/rubydex/src/model/definitions.rs +27 -26
  27. data/rust/rubydex/src/model/document.rs +43 -7
  28. data/rust/rubydex/src/model/graph.rs +40 -28
  29. data/rust/rubydex/src/model/id.rs +55 -0
  30. data/rust/rubydex/src/model/ids.rs +21 -9
  31. data/rust/rubydex/src/model/name.rs +35 -7
  32. data/rust/rubydex/src/model/references.rs +16 -13
  33. data/rust/rubydex/src/operation/ruby_builder.rs +48 -59
  34. data/rust/rubydex/src/query/cypher/schema.rs +790 -0
  35. data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
  36. data/rust/rubydex/src/query/cypher/tests.rs +228 -0
  37. data/rust/rubydex/src/query/cypher.rs +57 -0
  38. data/rust/rubydex/src/query.rs +2 -0
  39. data/rust/rubydex/src/resolution.rs +248 -227
  40. data/rust/rubydex/src/resolution_tests.rs +263 -65
  41. data/rust/rubydex-sys/src/declaration_api.rs +6 -3
  42. data/rust/rubydex-sys/src/definition_api.rs +27 -7
  43. data/rust/rubydex-sys/src/graph_api.rs +158 -14
  44. data/rust/rubydex-sys/src/reference_api.rs +58 -12
  45. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73a4ef87ec062ac40a438143ac91edf1388543ff336a45bc875582886dc5259b
4
- data.tar.gz: 5ba4364ae655fe44edd6897e6c53d95aaff4b970316076f9b538dbd52a6c936b
3
+ metadata.gz: cf823835a70dee735f2dc0e898eb1d47be47e4ab635a6be4de5677ceaf6bbe53
4
+ data.tar.gz: 663389c429d729bf8d9191f54b7eafe92b8ecb8e0c0a32e5b3f6adba3365f3fa
5
5
  SHA512:
6
- metadata.gz: c4575cfb038c95e6e86460f5dd832a79224428e05d7f39b9431b50ec4c10029e719862be06acf4fc974a15e210a6a6272de1e5d1193aeb4a0e5a390e7c48d5af
7
- data.tar.gz: d44dce9ff4da7f9332ce1c340087a87fc2655c3bc35ba33f76ad78fbe13774973b99c287aca0b92a65492b77b5b7425147144b1edbd0708f6047c19859ef71a7
6
+ metadata.gz: aa17ed0f03e392c4ad1a2bf256d3472057d7a78a2baca596bb72f80c06d1fe36d7fa80878ddfabe740ca0b8d23f961d01afe9ab0211219e2f41b36b4587b819d
7
+ data.tar.gz: 86d70527d4bcee2b8e90bbebb33912e1f5a6bf1f7fe9ce6e1435e7fff918829ae2a8de692072ab34fca42a199f5cfce5a12e18f77b6f08d5b8ca274c3e8f83f4
data/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  This project is a high-performance static analysis toolkit for the Ruby language. The goal is to be a solid
4
4
  foundation to power a variety of tools, such as type checkers, linters, language servers and more.
5
5
 
6
+ [Ruby API Documentation](https://shopify.github.io/rubydex/)
7
+
6
8
  ## Usage
7
9
 
8
10
  Both Ruby and Rust APIs are made available through a gem and a crate, respectively. Here's a simple example
@@ -77,6 +79,70 @@ diagnostic.message
77
79
  diagnostic.location
78
80
  ```
79
81
 
82
+ ## Ractor Safety
83
+
84
+ A resolved `Rubydex::Graph` can be shared across Ractors without copying:
85
+
86
+ ```ruby
87
+ graph = Rubydex::Graph.new
88
+ graph.index_workspace
89
+ graph.resolve
90
+ Ractor.make_shareable(graph)
91
+
92
+ # Worker Ractors can now read the graph in parallel
93
+ ractor = Ractor.new(graph) { |g| g["Foo"]&.name }
94
+ ractor.value
95
+ ```
96
+
97
+ Thread safety comes from an `RwLock` on the Rust side, **not** from Ruby's
98
+ `freeze`. This is a deliberate, but potentially surprising, choice:
99
+
100
+ - A frozen (or `make_shareable`'d) graph **can still be mutated** — methods like
101
+ `index_source`, `resolve`, `exclude_patterns`, and `encoding=` work on a frozen
102
+ graph. This supports interactive use cases (LSP/MCP) that need incremental
103
+ edits while worker Ractors read concurrently.
104
+ - Because the same underlying allocation is shared, callers are responsible for
105
+ ordering concurrent writes and reads, as with any shared mutable state across
106
+ Ractors.
107
+ - Graphs cannot be `dup`'d or `clone`'d; both raise `RuntimeError` to avoid
108
+ aliasing the Rust allocation (which would double-free on GC) or ballooning
109
+ memory with a deep copy.
110
+
111
+ ## Querying with Cypher
112
+
113
+ Rubydex exposes the indexed graph through a read-only subset of the
114
+ [Cypher](https://opencypher.org/) query language. Only read clauses (`MATCH`,
115
+ `WHERE`, `RETURN`, ...) are supported; there is no way to mutate the graph.
116
+
117
+ From the command line:
118
+
119
+ ```bash
120
+ # Run a query against the current workspace
121
+ bundle exec rdx query "MATCH (c:Class)-[:DEFINES]->(m:Method) RETURN c.name, m.name"
122
+
123
+ # Render results as JSON instead of a table
124
+ bundle exec rdx query "MATCH (c:Class) RETURN c.name" --format json
125
+
126
+ # Describe the queryable schema (node labels and relationship types) without indexing
127
+ bundle exec rdx query --schema
128
+ ```
129
+
130
+ From Ruby:
131
+
132
+ ```ruby
133
+ graph = Rubydex::Graph.new
134
+ graph.index_workspace
135
+ graph.resolve
136
+
137
+ # Parse once, render against a graph as a table or JSON string
138
+ query = Rubydex::Query.parse("MATCH (c:Class) RETURN c.name")
139
+ puts query.render(graph, "table")
140
+ puts query.render(graph, "json")
141
+
142
+ # Describe the schema
143
+ puts Rubydex::Query.schema("table")
144
+ ```
145
+
80
146
  ## MCP Server (Experimental)
81
147
 
82
148
  Rubydex can run as an MCP (Model Context Protocol) server, enabling AI assistants
@@ -94,16 +160,16 @@ like Claude to semantically query your Ruby codebase.
94
160
  bundle install
95
161
  ```
96
162
 
97
- 3. Configure your MCP client to run `bundle exec rdx --mcp`.
163
+ 3. Configure your MCP client to run `bundle exec rdx mcp`.
98
164
 
99
165
  Using Claude Code as an example:
100
166
  ```bash
101
- claude mcp add --scope project rubydex -- bundle exec rdx --mcp
167
+ claude mcp add --scope project rubydex -- bundle exec rdx mcp
102
168
  ```
103
169
 
104
170
  Using Codex as an example:
105
171
  ```bash
106
- codex mcp add rubydex -- bundle exec rdx --mcp
172
+ codex mcp add rubydex -- bundle exec rdx mcp
107
173
  ```
108
174
 
109
175
  Start your MCP client from that project directory. The MCP server indexes
@@ -52,7 +52,7 @@
52
52
  <ul class="licenses-overview">
53
53
  <li><a href="#Apache-2.0">Apache License 2.0</a> (116)</li>
54
54
  <li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li>
55
- <li><a href="#MIT">MIT License</a> (16)</li>
55
+ <li><a href="#MIT">MIT License</a> (17)</li>
56
56
  <li><a href="#BSD-2-Clause">BSD 2-Clause &quot;Simplified&quot; License</a> (2)</li>
57
57
  <li><a href="#BSD-3-Clause">BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License</a> (1)</li>
58
58
  <li><a href="#BSL-1.0">Boost Software License 1.0</a> (1)</li>
@@ -2802,6 +2802,39 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
2802
2802
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2803
2803
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2804
2804
  SOFTWARE.
2805
+ </pre>
2806
+ </li>
2807
+ <li class="license">
2808
+ <h3 id="MIT">MIT License</h3>
2809
+ <h4>Used by:</h4>
2810
+ <ul class="license-used-by">
2811
+ <li><a
2812
+ href=" https://github.com/Shopify/cypher-parser ">cypher-parser
2813
+ 0.2.0</a></li>
2814
+ </ul>
2815
+ <pre class="license-text">The MIT License (MIT)
2816
+
2817
+ Copyright (c) 2025-present, Shopify Inc.
2818
+
2819
+ Permission is hereby granted, free of charge, to any person obtaining a copy
2820
+ of this software and associated documentation files (the &quot;Software&quot;), to deal
2821
+ in the Software without restriction, including without limitation the rights
2822
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
2823
+ copies of the Software, and to permit persons to whom the Software is
2824
+ furnished to do so, subject to the following conditions:
2825
+
2826
+ The above copyright notice and this permission notice shall be included in
2827
+ all copies or substantial portions of the Software.
2828
+
2829
+ THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
2830
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
2831
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
2832
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
2833
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2834
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2835
+ THE SOFTWARE.
2836
+
2837
+ See the generated THIRD_PARTY_LICENSES.html file for third party licenses.
2805
2838
  </pre>
2806
2839
  </li>
2807
2840
  <li class="license">
data/exe/rdx CHANGED
@@ -5,75 +5,151 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
5
 
6
6
  require "optparse"
7
7
 
8
- options = {}
8
+ USAGE = <<~TEXT
9
+ Usage: rdx <command> [options]
9
10
 
10
- option_parser = OptionParser.new do |parser|
11
- parser.on("--version", "Print the gem's version") do
11
+ Commands:
12
+ query <CYPHER> Run a Cypher query against the workspace graph and print the result.
13
+ Use `query --schema` to describe the queryable schema (labels,
14
+ relationships, properties) without indexing the workspace.
15
+ console Open an interactive session with a populated graph for the current workspace
16
+ mcp [PATH] Run the MCP server for AI assistants (workspace defaults to the current dir)
17
+ help Show this help message
18
+
19
+ Run `rdx <command> --help` for command-specific options.
20
+ TEXT
21
+
22
+ def abort_with_usage(message)
23
+ warn(message)
24
+ warn("")
25
+ warn(USAGE)
26
+ exit(1)
27
+ end
28
+
29
+ # Top-level --version / --help / bare invocation, handled before command dispatch. Each branch
30
+ # performs its action and reports whether it handled the invocation, so we exit exactly once here
31
+ # rather than from inside every branch.
32
+ handled =
33
+ case ARGV.first
34
+ when "--version", "version"
12
35
  require "rubydex/version"
13
36
  puts "v#{Rubydex::VERSION}"
14
- exit
37
+ true
38
+ when nil, "-h", "--help", "help"
39
+ puts USAGE
40
+ true
41
+ else
42
+ false
15
43
  end
16
44
 
17
- parser.on("-h", "--help", "Prints this help") do
18
- puts parser
19
- exit
20
- end
45
+ exit if handled
21
46
 
22
- parser.on("-i", "--interactive", "Open an interactive session with a populated graph for the current workspace") do
23
- options[:interactive] = true
24
- end
47
+ command = ARGV.shift
25
48
 
26
- parser.on("--mcp", "Run the MCP server for AI assistants") do
27
- options[:mcp] = true
28
- end
49
+ def with_timer(io, message)
50
+ io.print(message)
51
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
52
+ yield
53
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
54
+ io.puts(" finished in #{duration.round(2)}ms")
29
55
  end
30
56
 
31
- begin
32
- arguments = option_parser.parse(ARGV)
33
- rescue OptionParser::ParseError => e
34
- warn("error: #{e.message}")
35
- warn
36
- warn(option_parser)
37
- exit(2)
57
+ # Builds the workspace graph, sending progress messages to `progress_io`.
58
+ def build_graph(progress_io)
59
+ graph = Rubydex::Graph.new
60
+ graph.load_config
61
+ with_timer(progress_io, "Indexing workspace...") { graph.index_workspace }
62
+ with_timer(progress_io, "Resolving graph...") { graph.resolve }
63
+ graph
38
64
  end
39
65
 
40
66
  require "rubydex"
41
67
 
42
- if options[:mcp]
43
- if arguments.length > 1
44
- warn("error: unexpected argument '#{arguments[1]}' found")
45
- warn
46
- warn(option_parser)
47
- exit(2)
48
- end
68
+ # Resolve the command into an operation on a populated graph. Each command parses its own options
69
+ # and does any graph-independent work here; a command that needs no graph (like `query --schema`)
70
+ # handles itself and exits. Whatever falls through returns a lambda that runs against the graph.
71
+ operation =
72
+ case command
73
+ when "query"
74
+ schema = false
75
+ format = "table"
76
+ OptionParser.new do |parser|
77
+ parser.banner = "Usage: rdx query <CYPHER> [options]"
78
+ parser.on("--schema", "Describe the queryable schema instead of running a query") { schema = true }
79
+ parser.on("--format FORMAT", ["table", "json"], "Output format (table or json)") { |value| format = value }
80
+ parser.on("-h", "--help", "Show this help") do
81
+ puts parser
82
+ exit
83
+ end
84
+ end.parse!
49
85
 
50
- require "rubydex/mcp_server"
51
- Rubydex::MCPServer.run(arguments.fetch(0, Dir.pwd))
52
- exit
53
- end
86
+ query = ARGV.shift
54
87
 
55
- def __with_timer(message, &block)
56
- print(message)
57
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
58
- block.call
59
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
60
- puts " finished in #{duration.round(2)}ms"
61
- end
88
+ if schema
89
+ warn("warning: ignoring query argument because `--schema` was given") if query
90
+ # The schema is static, so describe it without building a graph, then exit.
91
+ print(Rubydex::Query.schema(format))
92
+ exit
93
+ end
94
+
95
+ abort_with_usage("`query` requires a Cypher query argument (or pass `--schema`)") if query.nil? || query.empty?
96
+
97
+ # Parse the query up front so a malformed query fails fast, before the expensive indexing below.
98
+ parsed = begin
99
+ Rubydex::Query.parse(query)
100
+ rescue ArgumentError => e
101
+ abort(e.message)
102
+ end
62
103
 
63
- graph = Rubydex::Graph.new
64
- graph.load_config
65
-
66
- __with_timer("Indexing workspace...") { graph.index_workspace }
67
- __with_timer("Resolving graph...") { graph.resolve }
68
-
69
- if options[:interactive]
70
- begin
71
- require "irb"
72
- IRB.setup(nil)
73
- IRB.conf[:IRB_NAME] = "rubydex"
74
- workspace = IRB::WorkSpace.new(binding)
75
- IRB::Irb.new(workspace).run(IRB.conf)
76
- rescue LoadError
77
- abort("Interactive mode requires `irb` to be in the bundle")
104
+ lambda do |graph|
105
+ print(parsed.render(graph, format))
106
+ rescue ArgumentError => e
107
+ abort(e.message)
108
+ end
109
+ when "console"
110
+ OptionParser.new do |parser|
111
+ parser.banner = "Usage: rdx console"
112
+ parser.on("-h", "--help", "Show this help") do
113
+ puts parser
114
+ exit
115
+ end
116
+ end.parse!
117
+
118
+ lambda do |graph|
119
+ require "irb"
120
+ IRB.setup(nil)
121
+ IRB.conf[:IRB_NAME] = "rubydex"
122
+ IRB::Irb.new(IRB::WorkSpace.new(binding)).run(IRB.conf)
123
+ rescue LoadError
124
+ abort("Interactive mode requires `irb` to be in the bundle")
125
+ end
126
+ when "mcp"
127
+ parser = OptionParser.new do |p|
128
+ p.banner = "Usage: rdx mcp [PATH]"
129
+ p.on("-h", "--help", "Show this help") do
130
+ puts p
131
+ exit
132
+ end
133
+ end
134
+ begin
135
+ parser.parse!
136
+ rescue OptionParser::ParseError => e
137
+ abort_with_usage(e.message)
138
+ end
139
+
140
+ path = ARGV.shift || Dir.pwd
141
+ abort_with_usage("unexpected argument: #{ARGV.first}") unless ARGV.empty?
142
+
143
+ # The MCP server manages its own graph lifecycle for the given workspace, so it runs here
144
+ # instead of falling through to the shared graph build below.
145
+ require "rubydex/mcp_server"
146
+ Rubydex::MCPServer.run(path)
147
+ exit
148
+ else
149
+ abort_with_usage("unknown command: #{command}")
78
150
  end
79
- end
151
+
152
+ # Everything that reaches here operates on a populated graph. Progress goes to stderr so stdout
153
+ # carries only the command's output (e.g. for piping a query's JSON).
154
+ graph = build_graph($stderr)
155
+ operation.call(graph)
@@ -236,7 +236,7 @@ static VALUE rdxr_declaration_owner(VALUE self) {
236
236
  const CDeclaration *decl = rdx_declaration_owner(graph, data->id);
237
237
 
238
238
  if (decl == NULL) {
239
- rb_raise(rb_eRuntimeError, "owner can never be nil for any declarations");
239
+ rb_raise(rb_eRuntimeError, "Declaration must exist for a valid id");
240
240
  }
241
241
 
242
242
  VALUE decl_class = rdxi_declaration_class_for_kind(decl->kind);
@@ -18,6 +18,7 @@ static VALUE mRubydex;
18
18
  static VALUE cInclude;
19
19
  static VALUE cPrepend;
20
20
  static VALUE cExtend;
21
+ static VALUE cDocument;
21
22
  VALUE cComment;
22
23
  VALUE cDefinition;
23
24
  VALUE cClassDefinition;
@@ -88,6 +89,9 @@ static VALUE rdxr_definition_location(VALUE self) {
88
89
  void *graph = rdxi_graph_from_handle(self, &data);
89
90
 
90
91
  Location *loc = rdx_definition_location(graph, data->id);
92
+ if (loc == NULL) {
93
+ rb_raise(rb_eRuntimeError, "Definition must exist for a valid id");
94
+ }
91
95
  VALUE location = rdxi_build_location_value(loc);
92
96
  rdx_location_free(loc);
93
97
 
@@ -105,10 +109,11 @@ static VALUE rdxr_definition_comments(VALUE self) {
105
109
  void *graph = rdxi_graph_from_handle(self, &data);
106
110
 
107
111
  CommentArray *arr = rdx_definition_comments(graph, data->id);
108
- if (arr == NULL || arr->len == 0) {
109
- if (arr != NULL) {
110
- rdx_definition_comments_free(arr);
111
- }
112
+ if (arr == NULL) {
113
+ rb_raise(rb_eRuntimeError, "Definition must exist for a valid id");
114
+ }
115
+ if (arr->len == 0) {
116
+ rdx_definition_comments_free(arr);
112
117
  return rb_ary_new();
113
118
  }
114
119
 
@@ -261,6 +266,27 @@ static VALUE rdxr_definition_lexical_nesting(VALUE self) {
261
266
  return nesting;
262
267
  }
263
268
 
269
+ /*
270
+ * call-seq:
271
+ * document -> Rubydex::Document
272
+ *
273
+ * Returns the document this definition belongs to.
274
+ */
275
+ static VALUE rdxr_definition_document(VALUE self) {
276
+ HandleData *data;
277
+ void *graph = rdxi_graph_from_handle(self, &data);
278
+
279
+ const uint64_t *uri_id = rdx_definition_document(graph, data->id);
280
+ if (uri_id == NULL) {
281
+ rb_raise(rb_eRuntimeError, "Definition not found");
282
+ }
283
+
284
+ VALUE argv[] = {data->graph_obj, ULL2NUM(*uri_id)};
285
+ free_u64(uri_id);
286
+
287
+ return rb_class_new_instance(2, argv, cDocument);
288
+ }
289
+
264
290
  static VALUE rdxi_build_constant_reference(VALUE graph_obj, const CConstantReference *cref) {
265
291
  VALUE ref_class = (cref->declaration_id == 0)
266
292
  ? cUnresolvedConstantReference
@@ -397,6 +423,7 @@ void rdxi_initialize_definition(VALUE mod) {
397
423
  cInclude = rb_const_get(mRubydex, rb_intern("Include"));
398
424
  cPrepend = rb_const_get(mRubydex, rb_intern("Prepend"));
399
425
  cExtend = rb_const_get(mRubydex, rb_intern("Extend"));
426
+ cDocument = rb_const_get(mRubydex, rb_intern("Document"));
400
427
 
401
428
  cComment = rb_define_class_under(mRubydex, "Comment", rb_cObject);
402
429
 
@@ -412,6 +439,7 @@ void rdxi_initialize_definition(VALUE mod) {
412
439
  rb_define_method(cDefinition, "declaration", rdxr_definition_declaration, 0);
413
440
  rb_define_method(cDefinition, "lexical_owner", rdxr_definition_lexical_owner, 0);
414
441
  rb_define_method(cDefinition, "lexical_nesting", rdxr_definition_lexical_nesting, 0);
442
+ rb_define_method(cDefinition, "document", rdxr_definition_document, 0);
415
443
 
416
444
  cClassDefinition = rb_define_class_under(mRubydex, "ClassDefinition", cDefinition);
417
445
  rb_define_method(cClassDefinition, "superclass", rdxr_class_definition_superclass, 0);
data/ext/rubydex/graph.c CHANGED
@@ -63,16 +63,25 @@ const rb_data_type_t graph_type = {
63
63
  },
64
64
  .parent = NULL,
65
65
  .data = NULL,
66
- .flags = RUBY_TYPED_FREE_IMMEDIATELY,
66
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_FROZEN_SHAREABLE,
67
67
  };
68
68
 
69
- // Custom allocator for the Graph class. Calls into Rust to create a new `Arc<Mutex<Graph>>` that gets stored internally
70
- // as a void pointer
69
+ // Custom allocator for the Graph class. Calls into Rust to create a new Box<RwLock<Graph>> that gets stored
70
+ // internally as an opaque void pointer. The RwLock provides thread-safe concurrent access across Ractors.
71
71
  static VALUE rdxr_graph_alloc(VALUE klass) {
72
72
  void *graph = rdx_graph_new();
73
73
  return TypedData_Wrap_Struct(klass, &graph_type, graph);
74
74
  }
75
75
 
76
+ // Graph is intentionally non-copyable. A dup/clone of the TypedData wrapper would alias the
77
+ // same underlying Rust allocation (double-free on GC) or, if deep-copied, balloon memory use.
78
+ // Block both paths: `dup` dispatches to `initialize_copy`, `clone` to `initialize_clone`.
79
+ static VALUE rdxr_graph_initialize_copy(VALUE self, VALUE other) {
80
+ (void)self;
81
+ (void)other;
82
+ rb_raise(rb_eRuntimeError, "Rubydex::Graph cannot be duplicated or cloned");
83
+ }
84
+
76
85
  /*
77
86
  * call-seq:
78
87
  * index_all(file_paths) -> Array[String]
@@ -943,6 +952,8 @@ void rdxi_initialize_graph(VALUE moduleRubydex) {
943
952
  id_self_receiver = rb_intern("self_receiver");
944
953
 
945
954
  rb_define_alloc_func(cGraph, rdxr_graph_alloc);
955
+ rb_define_method(cGraph, "initialize_copy", rdxr_graph_initialize_copy, 1);
956
+ rb_define_method(cGraph, "initialize_clone", rdxr_graph_initialize_copy, 1);
946
957
  rb_define_method(cGraph, "index_all", rdxr_graph_index_all, 1);
947
958
  rb_define_method(cGraph, "index_source", rdxr_graph_index_source, 3);
948
959
  rb_define_method(cGraph, "document", rdxr_graph_document, 1);
@@ -0,0 +1,105 @@
1
+ #include "query.h"
2
+ #include "graph.h"
3
+ #include "rustbindings.h"
4
+ #include "utils.h"
5
+
6
+ /*
7
+ * call-seq:
8
+ * Rubydex::Query.schema(format = :table) -> String
9
+ *
10
+ * Returns a description of the queryable Cypher schema. +format+ may be +:table+ (default) or
11
+ * +:json+. The schema is static, so it does not require a graph.
12
+ */
13
+ static VALUE rdxr_cypher_schema(int argc, VALUE *argv, VALUE self) {
14
+ VALUE format;
15
+ rb_scan_args(argc, argv, "01", &format);
16
+
17
+ const char *output = rdx_cypher_schema(rdxi_symbol_or_string_cstr(format, "table"));
18
+ VALUE result = output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(output);
19
+ if (output != NULL) {
20
+ free_c_string(output);
21
+ }
22
+
23
+ return result;
24
+ }
25
+
26
+ // Free function for Rubydex::Query: releases the parsed query allocated by Rust.
27
+ static void query_free(void *ptr) {
28
+ if (ptr) {
29
+ rdx_cypher_query_free(ptr);
30
+ }
31
+ }
32
+
33
+ static const rb_data_type_t query_type = {
34
+ .wrap_struct_name = "Rubydex::Query",
35
+ .function = {
36
+ .dmark = NULL,
37
+ .dfree = query_free,
38
+ .dsize = NULL,
39
+ .dcompact = NULL,
40
+ },
41
+ .parent = NULL,
42
+ .data = NULL,
43
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY,
44
+ };
45
+
46
+ /*
47
+ * call-seq:
48
+ * Rubydex::Query.parse(query) -> Rubydex::Query
49
+ *
50
+ * Parses a Cypher query into an opaque, reusable object without needing a graph. Raises
51
+ * ArgumentError on a syntax error, so a query can be validated before building a graph.
52
+ */
53
+ static VALUE rdxr_query_parse(VALUE klass, VALUE query) {
54
+ Check_Type(query, T_STRING);
55
+
56
+ struct CParseResult result = rdx_cypher_parse(StringValueCStr(query));
57
+ if (result.error != NULL) {
58
+ VALUE message = rb_utf8_str_new_cstr(result.error);
59
+ free_c_string(result.error);
60
+ rb_raise(rb_eArgError, "%s", StringValueCStr(message));
61
+ }
62
+
63
+ return TypedData_Wrap_Struct(klass, &query_type, result.query);
64
+ }
65
+
66
+ /*
67
+ * call-seq:
68
+ * render(graph, format = :table) -> String
69
+ *
70
+ * Runs this parsed query against +graph+ and returns the formatted output. +format+ may be
71
+ * +:table+ (default) or +:json+. Raises ArgumentError on an execution or format error.
72
+ */
73
+ static VALUE rdxr_query_render(int argc, VALUE *argv, VALUE self) {
74
+ VALUE graph_obj, format;
75
+ rb_scan_args(argc, argv, "11", &graph_obj, &format);
76
+
77
+ void *query;
78
+ TypedData_Get_Struct(self, void *, &query_type, query);
79
+
80
+ void *graph;
81
+ TypedData_Get_Struct(graph_obj, void *, &graph_type, graph);
82
+
83
+ struct CQueryResult result = rdx_query_run(query, graph, rdxi_symbol_or_string_cstr(format, "table"));
84
+
85
+ if (result.error != NULL) {
86
+ VALUE message = rb_utf8_str_new_cstr(result.error);
87
+ free_c_string(result.error);
88
+ rb_raise(rb_eArgError, "%s", StringValueCStr(message));
89
+ }
90
+
91
+ VALUE output = result.output == NULL ? rb_utf8_str_new_cstr("") : rb_utf8_str_new_cstr(result.output);
92
+ if (result.output != NULL) {
93
+ free_c_string(result.output);
94
+ }
95
+
96
+ return output;
97
+ }
98
+
99
+ void rdxi_initialize_query(VALUE mRubydex) {
100
+ VALUE cQuery = rb_define_class_under(mRubydex, "Query", rb_cObject);
101
+ rb_undef_alloc_func(cQuery);
102
+ rb_define_singleton_method(cQuery, "parse", rdxr_query_parse, 1);
103
+ rb_define_singleton_method(cQuery, "schema", rdxr_cypher_schema, -1);
104
+ rb_define_method(cQuery, "render", rdxr_query_render, -1);
105
+ }
@@ -0,0 +1,8 @@
1
+ #ifndef RUBYDEX_QUERY_H
2
+ #define RUBYDEX_QUERY_H
3
+
4
+ #include "ruby.h"
5
+
6
+ void rdxi_initialize_query(VALUE mRubydex);
7
+
8
+ #endif // RUBYDEX_QUERY_H