rubydex 0.1.0.beta3-x86_64-linux → 0.1.0.beta4-x86_64-linux

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2de40c7335743a85b7d61d9a513d40b29fcecf62e19cfff8ef2637cd4297b6a8
4
- data.tar.gz: 99bed4d413ab7e1bd97f76965906037d1da29f12af8dba7f19f48d5665c73bc5
3
+ metadata.gz: 5092c197150819521964a1d1d88c5080da95f9a6ca24e11837acf5dd51b6780b
4
+ data.tar.gz: 3aee088ac6701a796266b4f53a0cdebc67077c67387690c1f74fdbac0cc7b498
5
5
  SHA512:
6
- metadata.gz: 29ef5b84c94af6eb1608d42acbc7a0c5c115fef6317645b369fca27c1c1714c64a21dba7a1b10f490939fbf547583fbed20db30d26e9ad4f42b9c8079c8b7e6c
7
- data.tar.gz: e4d9dce9acf24ef5c704dab14d89a82143c39e259e8923fc71830abe600b54240094a8e287aa647b62e419fac42db6e1c3f4b59d20b29498d1b5872865083dd5
6
+ metadata.gz: 5d5fbd813087afac50aa5a09985a6dfd11a1432a63261931e4ffe78e1a9b39df65a3cb09c62154ef40b740047bded05e649ab4aad44faa9169ce45de7312a495
7
+ data.tar.gz: '09cc63dc59639e3636ff5fff1847c3dd1c12cf05108ee5e25670c7787179b9eb9a0c3f795bb6ab191d590357d1990e5898b7e4086b9bba34ef20d536d66e5808'
data/README.md CHANGED
@@ -11,13 +11,70 @@ of using the Ruby API:
11
11
  ```ruby
12
12
  # Create a new graph representing the current workspace
13
13
  graph = Rubydex::Graph.new
14
+ # Configuring graph LSP encoding
15
+ graph.set_encoding("utf16")
14
16
  # Index the entire workspace with all dependencies
15
17
  graph.index_workspace
18
+ # Or index specific file paths
19
+ graph.index_all(["path/to/file.rb"])
16
20
  # Transform the initially collected information into its semantic understanding by running resolution
17
21
  graph.resolve
22
+ # Get all diagnostics acquired during the analysis
23
+ graph.diagnostics
18
24
 
19
- # Access the information as needed
20
- graph["Foo"]
25
+ # Iterating over graph nodes
26
+ graph.declarations
27
+ graph.documents
28
+ graph.constant_references
29
+ graph.method_references
30
+
31
+ # Analyzing require paths
32
+ graph.resolve_require_path("rails/engine", load_paths) # => document pointed by `rails/engine`
33
+ graph.require_paths(load_paths) # => array of all indexed require paths
34
+
35
+ # Querying
36
+ graph["Foo"] # Get declaration by fully qualified name
37
+ graph.search("Foo#b") # Name search
38
+ graph.resolve_constant("Bar", ["Foo", "Baz::Qux"]) # Resolve constant reference based on nesting
39
+
40
+ # Declarations
41
+ declaration = graph["Foo"]
42
+
43
+ # All declarations include
44
+ declaration.name
45
+ declaration.unqualified_name
46
+ declaration.definitions
47
+ declaration.owner
48
+
49
+ # Namespace declarations include
50
+ declaration.member("bar()")
51
+ declaration.member("@ivar")
52
+ declaration.singleton_class
53
+ declaration.ancestors
54
+ declaration.descendants
55
+
56
+ # Documents
57
+ document = graph.documents.first
58
+ document.uri
59
+ document.definitions # => list of definitions discovered in this document
60
+
61
+ # Definitions
62
+ definition = declaration.definitions.first
63
+ definition.location
64
+ definition.comments
65
+ definition.name
66
+ definition.deprecated?
67
+ definition.name_location
68
+
69
+ # Locations
70
+ location = definition.location
71
+ location.path
72
+
73
+ # Diagnostics
74
+ diagnostic = graph.diagnostics.first
75
+ diagnostic.rule
76
+ diagnostic.message
77
+ diagnostic.location
21
78
  ```
22
79
 
23
80
  ## Contributing
data/ext/rubydex/graph.c CHANGED
@@ -9,7 +9,6 @@
9
9
  #include "utils.h"
10
10
 
11
11
  static VALUE cGraph;
12
- static VALUE eIndexingError;
13
12
 
14
13
  // Free function for the custom Graph allocator. We always have to call into Rust to free data allocated by it
15
14
  static void graph_free(void *ptr) {
@@ -27,8 +26,8 @@ static VALUE rdxr_graph_alloc(VALUE klass) {
27
26
  return TypedData_Wrap_Struct(klass, &graph_type, graph);
28
27
  }
29
28
 
30
- // Graph#index_all: (Array[String] file_paths) -> nil
31
- // Raises IndexingError if anything failed during indexing
29
+ // Graph#index_all: (Array[String] file_paths) -> Array[String]
30
+ // Returns an array of IO error messages encountered during indexing
32
31
  static VALUE rdxr_graph_index_all(VALUE self, VALUE file_paths) {
33
32
  rdxi_check_array_of_strings(file_paths);
34
33
 
@@ -36,10 +35,12 @@ static VALUE rdxr_graph_index_all(VALUE self, VALUE file_paths) {
36
35
  size_t length = RARRAY_LEN(file_paths);
37
36
  char **converted_file_paths = rdxi_str_array_to_char(file_paths, length);
38
37
 
39
- // Get the underying graph pointer and then invoke the Rust index all implementation
38
+ // Get the underlying graph pointer and then invoke the Rust index all implementation
40
39
  void *graph;
41
40
  TypedData_Get_Struct(self, void *, &graph_type, graph);
42
- const char *error_messages = rdx_index_all(graph, (const char **)converted_file_paths, length);
41
+
42
+ size_t error_count = 0;
43
+ const char *const *errors = rdx_index_all(graph, (const char **)converted_file_paths, length, &error_count);
43
44
 
44
45
  // Free the converted file paths and allow the GC to collect them
45
46
  for (size_t i = 0; i < length; i++) {
@@ -47,15 +48,17 @@ static VALUE rdxr_graph_index_all(VALUE self, VALUE file_paths) {
47
48
  }
48
49
  free(converted_file_paths);
49
50
 
50
- // If indexing errors were returned, turn them into a Ruby string, call Rust to free the CString it allocated and
51
- // return the Ruby string
52
- if (error_messages != NULL) {
53
- VALUE error_string = rb_utf8_str_new_cstr(error_messages);
54
- free_c_string(error_messages);
55
- rb_raise(eIndexingError, "%s", StringValueCStr(error_string));
51
+ if (errors == NULL) {
52
+ return rb_ary_new();
56
53
  }
57
54
 
58
- return Qnil;
55
+ VALUE array = rb_ary_new_capa((long)error_count);
56
+ for (size_t i = 0; i < error_count; i++) {
57
+ rb_ary_push(array, rb_utf8_str_new_cstr(errors[i]));
58
+ }
59
+
60
+ free_c_string_array(errors, error_count);
61
+ return array;
59
62
  }
60
63
 
61
64
  // Size function for the declarations enumerator
@@ -272,6 +275,25 @@ static VALUE rdxr_graph_method_references(VALUE self) {
272
275
  return self;
273
276
  }
274
277
 
278
+ // Graph#delete_document: (String uri) -> Document?
279
+ // Deletes a document and all of its definitions from the graph.
280
+ // Returns the removed Document or nil if it doesn't exist.
281
+ static VALUE rdxr_graph_delete_document(VALUE self, VALUE uri) {
282
+ Check_Type(uri, T_STRING);
283
+
284
+ void *graph;
285
+ TypedData_Get_Struct(self, void *, &graph_type, graph);
286
+ const uint64_t *uri_id = rdx_graph_delete_document(graph, StringValueCStr(uri));
287
+
288
+ if (uri_id == NULL) {
289
+ return Qnil;
290
+ }
291
+
292
+ VALUE argv[] = {self, ULL2NUM(*uri_id)};
293
+ free_u64(uri_id);
294
+ return rb_class_new_instance(2, argv, cDocument);
295
+ }
296
+
275
297
  // Graph#resolve: () -> self
276
298
  // Runs the resolver to compute declarations and ownership
277
299
  static VALUE rdxr_graph_resolve(VALUE self) {
@@ -358,6 +380,38 @@ static VALUE rdxr_graph_resolve_require_path(VALUE self, VALUE require_path, VAL
358
380
  return rb_class_new_instance(2, argv, cDocument);
359
381
  }
360
382
 
383
+ // Graph#require_paths: (Array[String] load_path) -> Array[String]
384
+ // Returns all require paths for completion.
385
+ static VALUE rdxr_graph_require_paths(VALUE self, VALUE load_path) {
386
+ rdxi_check_array_of_strings(load_path);
387
+
388
+ void *graph;
389
+ TypedData_Get_Struct(self, void *, &graph_type, graph);
390
+
391
+ size_t paths_len = RARRAY_LEN(load_path);
392
+ char **converted_paths = rdxi_str_array_to_char(load_path, paths_len);
393
+
394
+ size_t out_count = 0;
395
+ const char *const *results = rdx_require_paths(graph, (const char **)converted_paths, paths_len, &out_count);
396
+
397
+ for (size_t i = 0; i < paths_len; i++) {
398
+ free(converted_paths[i]);
399
+ }
400
+ free(converted_paths);
401
+
402
+ if (results == NULL) {
403
+ return rb_ary_new();
404
+ }
405
+
406
+ VALUE array = rb_ary_new_capa((long)out_count);
407
+ for (size_t i = 0; i < out_count; i++) {
408
+ rb_ary_push(array, rb_utf8_str_new_cstr(results[i]));
409
+ }
410
+
411
+ free_c_string_array(results, out_count);
412
+ return array;
413
+ }
414
+
361
415
  // Graph#diagnostics -> Array[Rubydex::Diagnostic]
362
416
  static VALUE rdxr_graph_diagnostics(VALUE self) {
363
417
  void *graph;
@@ -392,12 +446,10 @@ static VALUE rdxr_graph_diagnostics(VALUE self) {
392
446
  }
393
447
 
394
448
  void rdxi_initialize_graph(VALUE mRubydex) {
395
- VALUE eRubydexError = rb_const_get(mRubydex, rb_intern("Error"));
396
- eIndexingError = rb_define_class_under(mRubydex, "IndexingError", eRubydexError);
397
-
398
449
  cGraph = rb_define_class_under(mRubydex, "Graph", rb_cObject);
399
450
  rb_define_alloc_func(cGraph, rdxr_graph_alloc);
400
451
  rb_define_method(cGraph, "index_all", rdxr_graph_index_all, 1);
452
+ rb_define_method(cGraph, "delete_document", rdxr_graph_delete_document, 1);
401
453
  rb_define_method(cGraph, "resolve", rdxr_graph_resolve, 0);
402
454
  rb_define_method(cGraph, "resolve_constant", rdxr_graph_resolve_constant, 2);
403
455
  rb_define_method(cGraph, "declarations", rdxr_graph_declarations, 0);
@@ -409,4 +461,5 @@ void rdxi_initialize_graph(VALUE mRubydex) {
409
461
  rb_define_method(cGraph, "search", rdxr_graph_search, 1);
410
462
  rb_define_method(cGraph, "set_encoding", rdxr_graph_set_encoding, 1);
411
463
  rb_define_method(cGraph, "resolve_require_path", rdxr_graph_resolve_require_path, 2);
464
+ rb_define_method(cGraph, "require_paths", rdxr_graph_require_paths, 1);
412
465
  }
Binary file
Binary file
Binary file
Binary file
data/lib/rubydex/graph.rb CHANGED
@@ -5,40 +5,68 @@ module Rubydex
5
5
  #
6
6
  # Note: this class is partially defined in C to integrate with the Rust backend
7
7
  class Graph
8
+ IGNORED_DIRECTORIES = [
9
+ ".bundle",
10
+ ".git",
11
+ ".github",
12
+ "node_modules",
13
+ "tmp",
14
+ ].freeze
15
+
8
16
  #: (?workspace_path: String) -> void
9
17
  def initialize(workspace_path: Dir.pwd)
10
18
  @workspace_path = workspace_path
11
19
  end
12
20
 
13
21
  # Index all files and dependencies of the workspace that exists in `@workspace_path`
14
- #: -> String?
22
+ #: -> Array[String]
15
23
  def index_workspace
16
- paths = Dir.glob("#{@workspace_path}/**/*.rb")
17
- paths.concat(
18
- workspace_dependency_paths.flat_map do |path|
19
- Dir.glob("#{path}/**/*.rb")
20
- end,
21
- )
22
- index_all(paths)
24
+ index_all(workspace_paths)
25
+ end
26
+
27
+ # Returns all workspace paths that should be indexed, excluding directories that we don't need to descend into such
28
+ # as `.git`, `node_modules`. Also includes any top level Ruby files
29
+ #
30
+ #: -> Array[String]
31
+ def workspace_paths
32
+ paths = []
33
+
34
+ Dir.each_child(@workspace_path) do |entry|
35
+ full_path = File.join(@workspace_path, entry)
36
+
37
+ if File.directory?(full_path)
38
+ paths << full_path unless IGNORED_DIRECTORIES.include?(entry)
39
+ elsif File.extname(entry) == ".rb"
40
+ paths << full_path
41
+ end
42
+ end
43
+
44
+ add_workspace_dependency_paths(paths)
45
+ paths.uniq!
46
+ paths
23
47
  end
24
48
 
25
49
  private
26
50
 
27
51
  # Gathers the paths we have to index for all workspace dependencies
28
- def workspace_dependency_paths
52
+ #: (Array[String]) -> void
53
+ def add_workspace_dependency_paths(paths)
29
54
  specs = Bundler.locked_gems&.specs
30
- return [] unless specs
55
+ return unless specs
31
56
 
32
- paths = specs.filter_map do |lazy_spec|
57
+ specs.each do |lazy_spec|
33
58
  spec = Gem::Specification.find_by_name(lazy_spec.name)
34
- spec.require_paths.map { |path| File.join(spec.full_gem_path, path) }
59
+ spec.require_paths.each do |path|
60
+ # For native extensions, RubyGems inserts an absolute require path pointing to
61
+ # `gems/some-gem-1.0.0/extensions`. Those paths don't actually include any Ruby files inside, so we can skip
62
+ # descending them
63
+ next if File.absolute_path?(path)
64
+
65
+ paths << File.join(spec.full_gem_path, path)
66
+ end
35
67
  rescue Gem::MissingSpecError
36
68
  nil
37
69
  end
38
-
39
- paths.flatten!
40
- paths.uniq!
41
- paths
42
70
  end
43
71
  end
44
72
  end
Binary file
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubydex
4
- VERSION = "0.1.0.beta3"
4
+ VERSION = "0.1.0.beta4"
5
5
  end
@@ -0,0 +1,29 @@
1
+ //! Visit the RBS AST and create type definitions.
2
+
3
+ use crate::{
4
+ indexing::local_graph::LocalGraph,
5
+ model::{document::Document, ids::UriId},
6
+ };
7
+
8
+ pub struct RBSIndexer {
9
+ #[allow(dead_code)]
10
+ uri_id: UriId,
11
+ local_graph: LocalGraph,
12
+ }
13
+
14
+ impl RBSIndexer {
15
+ #[must_use]
16
+ pub fn new(uri: String, source: &str) -> Self {
17
+ let uri_id = UriId::from(&uri);
18
+ let local_graph = LocalGraph::new(uri_id, Document::new(uri, source));
19
+
20
+ Self { uri_id, local_graph }
21
+ }
22
+
23
+ #[must_use]
24
+ pub fn local_graph(self) -> LocalGraph {
25
+ self.local_graph
26
+ }
27
+
28
+ pub fn index(&mut self) {}
29
+ }
@@ -1,6 +1,6 @@
1
1
  use crate::{
2
2
  errors::Errors,
3
- indexing::{local_graph::LocalGraph, ruby_indexer::RubyIndexer},
3
+ indexing::{local_graph::LocalGraph, rbs_indexer::RBSIndexer, ruby_indexer::RubyIndexer},
4
4
  job_queue::{Job, JobQueue},
5
5
  model::graph::Graph,
6
6
  };
@@ -9,16 +9,17 @@ use std::{fs, path::PathBuf, sync::Arc};
9
9
  use url::Url;
10
10
 
11
11
  pub mod local_graph;
12
+ pub mod rbs_indexer;
12
13
  pub mod ruby_indexer;
13
14
 
14
- /// Job that indexes a single Ruby file
15
- pub struct IndexingRubyFileJob {
15
+ /// Job that indexes a single file
16
+ pub struct IndexingJob {
16
17
  path: PathBuf,
17
18
  local_graph_tx: Sender<LocalGraph>,
18
19
  errors_tx: Sender<Errors>,
19
20
  }
20
21
 
21
- impl IndexingRubyFileJob {
22
+ impl IndexingJob {
22
23
  #[must_use]
23
24
  pub fn new(path: PathBuf, local_graph_tx: Sender<LocalGraph>, errors_tx: Sender<Errors>) -> Self {
24
25
  Self {
@@ -35,7 +36,7 @@ impl IndexingRubyFileJob {
35
36
  }
36
37
  }
37
38
 
38
- impl Job for IndexingRubyFileJob {
39
+ impl Job for IndexingJob {
39
40
  fn run(&self) {
40
41
  let Ok(source) = fs::read_to_string(&self.path) else {
41
42
  self.send_error(Errors::FileError(format!(
@@ -55,11 +56,20 @@ impl Job for IndexingRubyFileJob {
55
56
  return;
56
57
  };
57
58
 
58
- let mut ruby_indexer = RubyIndexer::new(url.to_string(), &source);
59
- ruby_indexer.index();
59
+ let local_graph = if let Some(ext) = self.path.extension()
60
+ && ext == "rbs"
61
+ {
62
+ let mut indexer = RBSIndexer::new(url.to_string(), &source);
63
+ indexer.index();
64
+ indexer.local_graph()
65
+ } else {
66
+ let mut indexer = RubyIndexer::new(url.to_string(), &source);
67
+ indexer.index();
68
+ indexer.local_graph()
69
+ };
60
70
 
61
71
  self.local_graph_tx
62
- .send(ruby_indexer.local_graph())
72
+ .send(local_graph)
63
73
  .expect("graph receiver dropped before merge");
64
74
  }
65
75
  }
@@ -75,7 +85,7 @@ pub fn index_files(graph: &mut Graph, paths: Vec<PathBuf>) -> Vec<Errors> {
75
85
  let (errors_tx, errors_rx) = unbounded();
76
86
 
77
87
  for path in paths {
78
- queue.push(Box::new(IndexingRubyFileJob::new(
88
+ queue.push(Box::new(IndexingJob::new(
79
89
  path,
80
90
  local_graphs_tx.clone(),
81
91
  errors_tx.clone(),
@@ -27,6 +27,16 @@ struct Args {
27
27
 
28
28
  #[arg(long = "stats", help = "Show detailed performance statistics")]
29
29
  stats: bool,
30
+
31
+ #[arg(
32
+ long = "report-orphans",
33
+ value_name = "PATH",
34
+ num_args = 0..=1,
35
+ require_equals = true,
36
+ default_missing_value = "/tmp/rubydex-orphan-report.txt",
37
+ help = "Write orphan definitions report to specified file"
38
+ )]
39
+ report_orphans: Option<String>,
30
40
  }
31
41
 
32
42
  #[derive(Debug, Clone, ValueEnum)]
@@ -101,6 +111,20 @@ fn main() {
101
111
  MemoryStats::print_memory_usage();
102
112
  }
103
113
 
114
+ // Orphan report
115
+ if let Some(ref path) = args.report_orphans {
116
+ match std::fs::File::create(path) {
117
+ Ok(mut file) => {
118
+ if let Err(e) = graph.write_orphan_report(&mut file) {
119
+ eprintln!("Failed to write orphan report: {e}");
120
+ } else {
121
+ println!("Orphan report written to {path}");
122
+ }
123
+ }
124
+ Err(e) => eprintln!("Failed to create orphan report file: {e}"),
125
+ }
126
+ }
127
+
104
128
  // Generate visualization or print statistics
105
129
  if args.visualize {
106
130
  println!("{}", dot::generate(&graph));
@@ -142,7 +142,16 @@ impl Definition {
142
142
  Definition::SingletonClass(d) => Some(d.name_id()),
143
143
  Definition::Module(d) => Some(d.name_id()),
144
144
  Definition::Constant(d) => Some(d.name_id()),
145
- _ => None,
145
+ Definition::ConstantAlias(d) => Some(d.name_id()),
146
+ Definition::GlobalVariable(_)
147
+ | Definition::InstanceVariable(_)
148
+ | Definition::ClassVariable(_)
149
+ | Definition::AttrAccessor(_)
150
+ | Definition::AttrReader(_)
151
+ | Definition::AttrWriter(_)
152
+ | Definition::Method(_)
153
+ | Definition::MethodAlias(_)
154
+ | Definition::GlobalVariableAlias(_) => None,
146
155
  }
147
156
  }
148
157
 
@@ -191,60 +191,42 @@ impl Graph {
191
191
  Definition::ConstantAlias(it) => {
192
192
  return self.name_id_to_declaration_id(*it.name_id());
193
193
  }
194
- Definition::GlobalVariable(it) => {
195
- let nesting_definition = it
196
- .lexical_nesting_id()
197
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
198
- (nesting_definition, it.str_id())
199
- }
200
- Definition::GlobalVariableAlias(it) => {
201
- let nesting_definition = it
202
- .lexical_nesting_id()
203
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
204
- (nesting_definition, it.new_name_str_id())
205
- }
206
- Definition::InstanceVariable(it) => {
207
- let nesting_definition = it
208
- .lexical_nesting_id()
209
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
210
- (nesting_definition, it.str_id())
211
- }
212
- Definition::ClassVariable(it) => {
213
- let nesting_definition = it
214
- .lexical_nesting_id()
215
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
216
- (nesting_definition, it.str_id())
217
- }
218
- Definition::AttrAccessor(it) => {
219
- let nesting_definition = it
220
- .lexical_nesting_id()
221
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
222
- (nesting_definition, it.str_id())
223
- }
224
- Definition::AttrReader(it) => {
225
- let nesting_definition = it
226
- .lexical_nesting_id()
227
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
228
- (nesting_definition, it.str_id())
229
- }
230
- Definition::AttrWriter(it) => {
231
- let nesting_definition = it
232
- .lexical_nesting_id()
233
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
234
- (nesting_definition, it.str_id())
235
- }
236
- Definition::Method(it) => {
237
- let nesting_definition = it
238
- .lexical_nesting_id()
239
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
240
- (nesting_definition, it.str_id())
241
- }
242
- Definition::MethodAlias(it) => {
243
- let nesting_definition = it
244
- .lexical_nesting_id()
245
- .and_then(|id| self.definitions().get(&id).unwrap().name_id());
246
- (nesting_definition, it.new_name_str_id())
247
- }
194
+ Definition::GlobalVariable(it) => (
195
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
196
+ it.str_id(),
197
+ ),
198
+ Definition::GlobalVariableAlias(it) => (
199
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
200
+ it.new_name_str_id(),
201
+ ),
202
+ Definition::InstanceVariable(it) => (
203
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
204
+ it.str_id(),
205
+ ),
206
+ Definition::ClassVariable(it) => (
207
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
208
+ it.str_id(),
209
+ ),
210
+ Definition::AttrAccessor(it) => (
211
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
212
+ it.str_id(),
213
+ ),
214
+ Definition::AttrReader(it) => (
215
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
216
+ it.str_id(),
217
+ ),
218
+ Definition::AttrWriter(it) => (
219
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
220
+ it.str_id(),
221
+ ),
222
+ Definition::Method(it) => (
223
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
224
+ it.str_id(),
225
+ ),
226
+ Definition::MethodAlias(it) => (
227
+ self.find_enclosing_namespace_name_id(it.lexical_nesting_id().as_ref()),
228
+ it.new_name_str_id(),
229
+ ),
248
230
  };
249
231
 
250
232
  let nesting_declaration_id = match nesting_name_id {
@@ -253,13 +235,28 @@ impl Graph {
253
235
  }?;
254
236
 
255
237
  self.declarations
256
- .get(nesting_declaration_id)
257
- .unwrap()
258
- .as_namespace()
259
- .unwrap()
238
+ .get(nesting_declaration_id)?
239
+ .as_namespace()?
260
240
  .member(member_str_id)
261
241
  }
262
242
 
243
+ /// Finds the closest namespace name ID to connect a definition to its declaration
244
+ fn find_enclosing_namespace_name_id(&self, starting_id: Option<&DefinitionId>) -> Option<&NameId> {
245
+ let mut current = starting_id;
246
+
247
+ while let Some(id) = current {
248
+ let def = self.definitions.get(id).unwrap();
249
+
250
+ if let Some(name_id) = def.name_id() {
251
+ return Some(name_id);
252
+ }
253
+
254
+ current = def.lexical_nesting_id().as_ref();
255
+ }
256
+
257
+ None
258
+ }
259
+
263
260
  #[must_use]
264
261
  pub fn name_id_to_declaration_id(&self, name_id: NameId) -> Option<&DeclarationId> {
265
262
  let name = self.names.get(&name_id);
@@ -515,11 +512,13 @@ impl Graph {
515
512
  }
516
513
  }
517
514
 
518
- //// Handles the deletion of a document identified by `uri`
519
- pub fn delete_uri(&mut self, uri: &str) {
515
+ /// Handles the deletion of a document identified by `uri`.
516
+ /// Returns the `UriId` of the removed document, or `None` if it didn't exist.
517
+ pub fn delete_document(&mut self, uri: &str) -> Option<UriId> {
520
518
  let uri_id = UriId::from(uri);
521
- self.remove_definitions_for_uri(uri_id);
522
- self.documents.remove(&uri_id);
519
+ let document = self.documents.remove(&uri_id)?;
520
+ self.remove_definitions_for_document(&document);
521
+ Some(uri_id)
523
522
  }
524
523
 
525
524
  /// Merges everything in `other` into this Graph. This method is meant to merge all graph representations from
@@ -581,18 +580,17 @@ impl Graph {
581
580
  // For each URI that was indexed through `other`, check what was discovered and update our current global
582
581
  // representation
583
582
  let uri_id = other.uri_id();
584
- self.remove_definitions_for_uri(uri_id);
583
+ if let Some(document) = self.documents.remove(&uri_id) {
584
+ self.remove_definitions_for_document(&document);
585
+ }
585
586
 
586
587
  self.extend(other);
587
588
  }
588
589
 
589
- // Removes all nodes and relationships associated to the given URI. This is used to clean up stale data when a
590
- // document (identified by `uri_id`) changes or when a document is closed and we need to clean up the memory
591
- fn remove_definitions_for_uri(&mut self, uri_id: UriId) {
592
- let Some(document) = self.documents.remove(&uri_id) else {
593
- return;
594
- };
595
-
590
+ // Removes all nodes and relationships associated to the given document. This is used to clean up stale data when a
591
+ // document changes or when a document is deleted and we need to clean up the memory.
592
+ // The document must already have been removed from `self.documents` before calling this.
593
+ fn remove_definitions_for_document(&mut self, document: &Document) {
596
594
  // TODO: Remove method references from method declarations once method inference is implemented
597
595
  for ref_id in document.method_references() {
598
596
  if let Some(method_ref) = self.method_references.remove(ref_id) {
@@ -1502,4 +1500,85 @@ mod tests {
1502
1500
  assert!(context.graph().get("Foo::<Foo>").is_none());
1503
1501
  assert!(context.graph().get("Foo::<Foo>::<<Foo>>").is_none());
1504
1502
  }
1503
+
1504
+ #[test]
1505
+ fn indexing_the_same_document_twice() {
1506
+ let mut context = GraphTest::new();
1507
+ let source = "
1508
+ module Bar; end
1509
+
1510
+ $global_var_1 = 1
1511
+ alias $global_alias_1 $global_var_1
1512
+ ALIAS_CONST_1 = Bar
1513
+
1514
+ class Foo
1515
+ alias $global_alias_2 $global_var_1
1516
+ attr_reader :attr_1
1517
+ attr_writer :attr_2
1518
+ attr_accessor :attr_3
1519
+ ALIAS_CONST_2 = Bar
1520
+
1521
+ $global_var_2 = 1
1522
+ @ivar_1 = 1
1523
+ @@class_var_1 = 1
1524
+
1525
+ def method_1
1526
+ $global_var_3 = 1
1527
+ @ivar_2 = 1
1528
+ @@class_var_2 = 1
1529
+ ALIAS_CONST_3 = Bar
1530
+ end
1531
+ alias_method :aliased_method_1, :method_1
1532
+
1533
+ def self.method_2
1534
+ $global_var_4 = 1
1535
+ @ivar_3 = 1
1536
+ @@class_var_3 = 1
1537
+ ALIAS_CONST_4 = Bar
1538
+ end
1539
+
1540
+ class << self
1541
+ alias $global_alias_3 $global_var_1
1542
+ attr_reader :attr_4
1543
+ attr_writer :attr_5
1544
+ attr_accessor :attr_6
1545
+ ALIAS_CONST_5 = Bar
1546
+
1547
+ $global_var_3 = 1
1548
+ @ivar_4 = 1
1549
+ @@class_var_4 = 1
1550
+
1551
+ def method_3
1552
+ $global_var_4 = 1
1553
+ @ivar_5 = 1
1554
+ @@class_var_5 = 1
1555
+ ALIAS_CONST_6 = Bar
1556
+ end
1557
+ alias_method :aliased_method_1, :method_1
1558
+
1559
+ def self.method_4
1560
+ $global_var_5 = 1
1561
+ @ivar_6 = 1
1562
+ @@class_var_6 = 1
1563
+ ALIAS_CONST_7 = Bar
1564
+ end
1565
+ end
1566
+ end
1567
+ ";
1568
+
1569
+ context.index_uri("file:///foo.rb", source);
1570
+ assert_eq!(44, context.graph().definitions.len());
1571
+ assert_eq!(7, context.graph().constant_references.len());
1572
+ assert_eq!(2, context.graph().method_references.len());
1573
+ assert_eq!(1, context.graph().documents.len());
1574
+ assert_eq!(12, context.graph().names.len());
1575
+ assert_eq!(41, context.graph().strings.len());
1576
+ context.index_uri("file:///foo.rb", source);
1577
+ assert_eq!(44, context.graph().definitions.len());
1578
+ assert_eq!(7, context.graph().constant_references.len());
1579
+ assert_eq!(2, context.graph().method_references.len());
1580
+ assert_eq!(1, context.graph().documents.len());
1581
+ assert_eq!(12, context.graph().names.len());
1582
+ assert_eq!(41, context.graph().strings.len());
1583
+ }
1505
1584
  }
@@ -4,7 +4,6 @@
4
4
  //! within a file. It can be used to track positions in source code and convert
5
5
  //! between byte offsets and line/column positions.
6
6
 
7
- #[cfg(any(test, feature = "test_utils"))]
8
7
  use crate::model::document::Document;
9
8
 
10
9
  /// Represents a byte offset range within a specific file.
@@ -59,7 +58,6 @@ impl Offset {
59
58
  }
60
59
 
61
60
  /// Converts an offset to a display range like `1:1-1:5`
62
- #[cfg(any(test, feature = "test_utils"))]
63
61
  #[must_use]
64
62
  pub fn to_display_range(&self, document: &Document) -> String {
65
63
  let line_index = document.line_index();
@@ -0,0 +1,262 @@
1
+ use std::collections::HashSet;
2
+ use std::io::Write;
3
+
4
+ use crate::model::declaration::Declaration;
5
+ use crate::model::definitions::Definition;
6
+ use crate::model::graph::Graph;
7
+ use crate::model::ids::{DefinitionId, NameId, StringId};
8
+ use crate::model::name::{NameRef, ParentScope};
9
+
10
+ impl Graph {
11
+ /// Writes a report of orphan definitions (definitions not linked to any declaration).
12
+ ///
13
+ /// Format: `type\tconcatenated_name\tlocation` (TSV)
14
+ ///
15
+ /// # Errors
16
+ ///
17
+ /// Returns an error if writing fails.
18
+ pub fn write_orphan_report(&self, writer: &mut impl Write) -> std::io::Result<()> {
19
+ // Collect all definition IDs that are linked to declarations
20
+ let linked_definition_ids: HashSet<&DefinitionId> = self
21
+ .declarations()
22
+ .values()
23
+ .flat_map(Declaration::definitions)
24
+ .collect();
25
+
26
+ // Find orphan definitions
27
+ let mut orphans: Vec<_> = self
28
+ .definitions()
29
+ .iter()
30
+ .filter(|(id, _)| !linked_definition_ids.contains(id))
31
+ .collect();
32
+
33
+ // Sort by type, then by location for consistent output
34
+ orphans.sort_by(|(_, a), (_, b)| {
35
+ a.kind()
36
+ .cmp(b.kind())
37
+ .then_with(|| a.uri_id().cmp(b.uri_id()))
38
+ .then_with(|| a.offset().cmp(b.offset()))
39
+ });
40
+
41
+ for (_, definition) in orphans {
42
+ let kind = definition.kind();
43
+ let name = match definition.name_id().copied() {
44
+ Some(id) => self.build_concatenated_name_from_name(id),
45
+ None => self.build_concatenated_name_from_lexical_nesting(definition),
46
+ };
47
+ let location = self.definition_location(definition);
48
+
49
+ writeln!(writer, "{kind}\t{name}\t{location}")?;
50
+ }
51
+
52
+ Ok(())
53
+ }
54
+
55
+ /// Walks the Name system's `parent_scope` chain to reconstruct the constant path.
56
+ /// Falls back to `nesting` for enclosing scope context when there is no explicit parent scope.
57
+ ///
58
+ /// Note: this produces a concatenated name by piecing together name parts, not a properly
59
+ /// resolved qualified name.
60
+ pub(crate) fn build_concatenated_name_from_name(&self, name_id: NameId) -> String {
61
+ let Some(name_ref) = self.names().get(&name_id) else {
62
+ return "<unknown>".to_string();
63
+ };
64
+ let simple_name = self.string_id_to_string(*name_ref.str());
65
+
66
+ match name_ref.parent_scope() {
67
+ ParentScope::Some(parent_id) | ParentScope::Attached(parent_id) => {
68
+ let parent_name = self.build_concatenated_name_from_name(*parent_id);
69
+ format!("{parent_name}::{simple_name}")
70
+ }
71
+ ParentScope::TopLevel => format!("::{simple_name}"),
72
+ ParentScope::None => {
73
+ let prefix = name_ref
74
+ .nesting()
75
+ .as_ref()
76
+ .map(|nesting_id| self.build_nesting_prefix(*nesting_id))
77
+ .unwrap_or_default();
78
+
79
+ if prefix.is_empty() {
80
+ simple_name
81
+ } else {
82
+ format!("{prefix}::{simple_name}")
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ /// Resolves the enclosing nesting `NameId` to a string prefix.
89
+ /// For resolved names, uses the declaration's fully qualified name.
90
+ /// For unresolved names, recursively walks the name chain.
91
+ fn build_nesting_prefix(&self, nesting_id: NameId) -> String {
92
+ let Some(name_ref) = self.names().get(&nesting_id) else {
93
+ return String::new();
94
+ };
95
+ match name_ref {
96
+ NameRef::Resolved(resolved) => self
97
+ .declarations()
98
+ .get(resolved.declaration_id())
99
+ .map_or_else(String::new, |decl| decl.name().to_string()),
100
+ NameRef::Unresolved(_) => self.build_concatenated_name_from_name(nesting_id),
101
+ }
102
+ }
103
+
104
+ /// Builds a concatenated name for non-constant definitions by walking the `lexical_nesting_id` chain.
105
+ ///
106
+ /// Note: this pieces together name parts from the lexical nesting, not a properly resolved
107
+ /// qualified name.
108
+ pub(crate) fn build_concatenated_name_from_lexical_nesting(&self, definition: &Definition) -> String {
109
+ let simple_name = self.string_id_to_string(self.definition_string_id(definition));
110
+
111
+ // Collect enclosing nesting names from inner to outer
112
+ let mut nesting_parts = Vec::new();
113
+ let mut current_nesting = *definition.lexical_nesting_id();
114
+
115
+ while let Some(nesting_id) = current_nesting {
116
+ let Some(nesting_def) = self.definitions().get(&nesting_id) else {
117
+ break;
118
+ };
119
+ nesting_parts.push(self.string_id_to_string(self.definition_string_id(nesting_def)));
120
+ current_nesting = *nesting_def.lexical_nesting_id();
121
+ }
122
+
123
+ if nesting_parts.is_empty() {
124
+ return simple_name;
125
+ }
126
+
127
+ // Reverse to get outer-to-inner order for the prefix
128
+ nesting_parts.reverse();
129
+ let prefix = nesting_parts.join("::");
130
+
131
+ let separator = match definition {
132
+ Definition::Method(_)
133
+ | Definition::AttrAccessor(_)
134
+ | Definition::AttrReader(_)
135
+ | Definition::AttrWriter(_)
136
+ | Definition::MethodAlias(_)
137
+ | Definition::InstanceVariable(_) => "#",
138
+ Definition::Class(_)
139
+ | Definition::SingletonClass(_)
140
+ | Definition::Module(_)
141
+ | Definition::Constant(_)
142
+ | Definition::ConstantAlias(_)
143
+ | Definition::GlobalVariable(_)
144
+ | Definition::ClassVariable(_)
145
+ | Definition::GlobalVariableAlias(_) => "::",
146
+ };
147
+
148
+ format!("{prefix}{separator}{simple_name}")
149
+ }
150
+
151
+ /// Converts a `StringId` to its string value.
152
+ fn string_id_to_string(&self, string_id: StringId) -> String {
153
+ self.strings().get(&string_id).unwrap().to_string()
154
+ }
155
+
156
+ /// Get location in the format of `uri#L<line>` for a definition.
157
+ /// The format is clickable in VS Code.
158
+ pub(crate) fn definition_location(&self, definition: &Definition) -> String {
159
+ let uri_id = definition.uri_id();
160
+
161
+ let Some(document) = self.documents().get(uri_id) else {
162
+ return format!("{uri_id}:<unknown>");
163
+ };
164
+
165
+ let uri = document.uri();
166
+ let line_index = document.line_index();
167
+ let start = line_index.line_col(definition.offset().start().into());
168
+ format!("{uri}#L{}", start.line + 1)
169
+ }
170
+ }
171
+
172
+ #[cfg(test)]
173
+ mod tests {
174
+ use crate::test_utils::GraphTest;
175
+
176
+ #[test]
177
+ fn build_concatenated_name_from_name_for_constants() {
178
+ let cases = vec![
179
+ ("class Foo; end", "Foo"),
180
+ ("module Foo; class Bar; end; end", "Foo::Bar"),
181
+ ("module Foo; module Bar; class Baz; end; end; end", "Foo::Bar::Baz"),
182
+ ];
183
+
184
+ for (source, expected_name) in cases {
185
+ let mut context = GraphTest::new();
186
+ context.index_uri("file:///test.rb", source);
187
+ context.resolve();
188
+
189
+ let definitions = context.graph().get(expected_name).unwrap();
190
+ let definition = definitions.first().unwrap();
191
+ let name_id = *definition.name_id().unwrap();
192
+ let actual = context.graph().build_concatenated_name_from_name(name_id);
193
+
194
+ assert_eq!(actual, expected_name, "For source: {source}");
195
+ }
196
+ }
197
+
198
+ #[test]
199
+ fn build_concatenated_name_from_lexical_nesting_for_methods() {
200
+ let cases = vec![
201
+ ("class Foo; def bar; end; end", "Foo#bar()"),
202
+ ("module Foo; class Bar; def baz; end; end; end", "Foo::Bar#baz()"),
203
+ ("def bar; end", "bar()"),
204
+ ];
205
+
206
+ for (source, expected_name) in cases {
207
+ let mut context = GraphTest::new();
208
+ // Index without resolution so methods remain orphans
209
+ context.index_uri("file:///test.rb", source);
210
+
211
+ let definition = context
212
+ .graph()
213
+ .definitions()
214
+ .values()
215
+ .find(|d| d.kind() == "Method" && d.name_id().is_none())
216
+ .unwrap_or_else(|| panic!("No Method definition without name_id found for source: {source}"));
217
+
218
+ let actual = context.graph().build_concatenated_name_from_lexical_nesting(definition);
219
+ assert_eq!(actual, expected_name, "For source: {source}");
220
+ }
221
+ }
222
+
223
+ #[test]
224
+ fn build_concatenated_name_from_lexical_nesting_for_instance_variables() {
225
+ let mut context = GraphTest::new();
226
+ context.index_uri("file:///test.rb", "class Foo; def initialize; @ivar = 1; end; end");
227
+
228
+ let definition = context
229
+ .graph()
230
+ .definitions()
231
+ .values()
232
+ .find(|d| d.kind() == "InstanceVariable")
233
+ .unwrap();
234
+
235
+ let actual = context.graph().build_concatenated_name_from_lexical_nesting(definition);
236
+ assert_eq!(actual, "Foo::initialize()#@ivar");
237
+ }
238
+
239
+ #[test]
240
+ fn definition_location_uses_clickable_uri_fragment() {
241
+ let mut context = GraphTest::new();
242
+ context.index_uri(
243
+ "file:///foo.rb",
244
+ "
245
+ class Foo
246
+ def bar
247
+ end
248
+ end
249
+ ",
250
+ );
251
+
252
+ let definition = context
253
+ .graph()
254
+ .definitions()
255
+ .values()
256
+ .find(|d| d.kind() == "Method")
257
+ .unwrap();
258
+
259
+ let actual = context.graph().definition_location(definition);
260
+ assert_eq!(actual, "file:///foo.rb#L2");
261
+ }
262
+ }
@@ -1,4 +1,6 @@
1
1
  pub mod memory;
2
+ // TODO: When the rubydex is stable enough, turn this into a debug-only feature or revisit if we still need it.
3
+ pub mod orphan_report;
2
4
  pub mod timer;
3
5
 
4
6
  /// Helper function to compute percentage
@@ -36,7 +36,7 @@ impl GraphTest {
36
36
  }
37
37
 
38
38
  pub fn delete_uri(&mut self, uri: &str) {
39
- self.graph.delete_uri(uri);
39
+ self.graph.delete_document(uri);
40
40
  }
41
41
 
42
42
  pub fn resolve(&mut self) {
@@ -115,7 +115,9 @@ pub unsafe extern "C" fn rdx_graph_resolve_constant(
115
115
  })
116
116
  }
117
117
 
118
- /// Indexes all given file paths in parallel using the provided Graph pointer
118
+ /// Indexes all given file paths in parallel using the provided Graph pointer.
119
+ /// Returns an array of error message strings and writes the count to `out_error_count`.
120
+ /// Returns NULL if there are no errors. Caller must free with `free_c_string_array`.
119
121
  ///
120
122
  /// # Panics
121
123
  ///
@@ -130,34 +132,57 @@ pub unsafe extern "C" fn rdx_index_all(
130
132
  pointer: GraphPointer,
131
133
  file_paths: *const *const c_char,
132
134
  count: usize,
133
- ) -> *const c_char {
135
+ out_error_count: *mut usize,
136
+ ) -> *const *const c_char {
134
137
  let file_paths: Vec<String> = unsafe { utils::convert_double_pointer_to_vec(file_paths, count).unwrap() };
135
- let (file_paths, errors) = listing::collect_file_paths(file_paths);
136
-
137
- if !errors.is_empty() {
138
- let error_messages = errors
139
- .iter()
140
- .map(std::string::ToString::to_string)
141
- .collect::<Vec<_>>()
142
- .join("\n");
143
-
144
- return CString::new(error_messages).unwrap().into_raw().cast_const();
145
- }
138
+ let (file_paths, listing_errors) = listing::collect_file_paths(file_paths);
146
139
 
147
140
  with_mut_graph(pointer, |graph| {
148
- let errors = indexing::index_files(graph, file_paths);
141
+ let indexing_errors = indexing::index_files(graph, file_paths);
149
142
 
150
- if !errors.is_empty() {
151
- let error_messages = errors
152
- .iter()
153
- .map(std::string::ToString::to_string)
154
- .collect::<Vec<_>>()
155
- .join("\n");
143
+ let all_errors: Vec<String> = listing_errors
144
+ .into_iter()
145
+ .chain(indexing_errors)
146
+ .map(|e| e.to_string())
147
+ .collect();
156
148
 
157
- return CString::new(error_messages).unwrap().into_raw().cast_const();
149
+ if all_errors.is_empty() {
150
+ unsafe { *out_error_count = 0 };
151
+ return ptr::null();
158
152
  }
159
153
 
160
- ptr::null()
154
+ let c_strings: Vec<*const c_char> = all_errors
155
+ .into_iter()
156
+ .filter_map(|string| {
157
+ CString::new(string)
158
+ .ok()
159
+ .map(|c_string| c_string.into_raw().cast_const())
160
+ })
161
+ .collect();
162
+
163
+ unsafe { *out_error_count = c_strings.len() };
164
+
165
+ let boxed = c_strings.into_boxed_slice();
166
+ Box::into_raw(boxed).cast::<*const c_char>()
167
+ })
168
+ }
169
+
170
+ /// Deletes a document and all of its definitions from the graph.
171
+ /// Returns a pointer to the URI ID if the document was found and removed, or NULL if it didn't exist.
172
+ /// Caller must free the returned pointer with `free_u64`.
173
+ ///
174
+ /// # Safety
175
+ ///
176
+ /// Expects both the graph pointer and uri string pointer to be valid
177
+ #[unsafe(no_mangle)]
178
+ pub unsafe extern "C" fn rdx_graph_delete_document(pointer: GraphPointer, uri: *const c_char) -> *const u64 {
179
+ let Ok(uri_str) = (unsafe { utils::convert_char_ptr_to_string(uri) }) else {
180
+ return ptr::null();
181
+ };
182
+
183
+ with_mut_graph(pointer, |graph| match graph.delete_document(&uri_str) {
184
+ Some(uri_id) => Box::into_raw(Box::new(*uri_id)),
185
+ None => ptr::null(),
161
186
  })
162
187
  }
163
188
 
@@ -443,6 +468,44 @@ pub unsafe extern "C" fn rdx_resolve_require_path(
443
468
  })
444
469
  }
445
470
 
471
+ /// Returns all require paths for completion.
472
+ /// Returns array of C strings and writes count to `out_count`.
473
+ /// Returns null if `load_path` contain invalid UTF-8.
474
+ /// Caller must free with `free_c_string_array`.
475
+ ///
476
+ /// # Safety
477
+ /// - `pointer` must be a valid `GraphPointer` previously returned by this crate.
478
+ /// - `load_path` must be an array of `load_path_count` valid, null-terminated UTF-8 strings.
479
+ /// - `out_count` must be a valid, writable pointer.
480
+ #[unsafe(no_mangle)]
481
+ pub unsafe extern "C" fn rdx_require_paths(
482
+ pointer: GraphPointer,
483
+ load_path: *const *const c_char,
484
+ load_path_count: usize,
485
+ out_count: *mut usize,
486
+ ) -> *const *const c_char {
487
+ let Ok(paths_vec) = (unsafe { utils::convert_double_pointer_to_vec(load_path, load_path_count) }) else {
488
+ return ptr::null_mut();
489
+ };
490
+ let paths_vec = paths_vec.into_iter().map(PathBuf::from).collect::<Vec<_>>();
491
+
492
+ let results = with_graph(pointer, |graph| query::require_paths(graph, &paths_vec));
493
+
494
+ let c_strings: Vec<*const c_char> = results
495
+ .into_iter()
496
+ .filter_map(|string| {
497
+ CString::new(string)
498
+ .ok()
499
+ .map(|c_string| c_string.into_raw().cast_const())
500
+ })
501
+ .collect();
502
+
503
+ unsafe { *out_count = c_strings.len() };
504
+
505
+ let boxed = c_strings.into_boxed_slice();
506
+ Box::into_raw(boxed).cast::<*const c_char>()
507
+ }
508
+
446
509
  #[cfg(test)]
447
510
  mod tests {
448
511
  use rubydex::indexing::ruby_indexer::RubyIndexer;
@@ -48,3 +48,23 @@ pub extern "C" fn free_u64(ptr: *const u64) {
48
48
  let _ = Box::from_raw(ptr.cast_mut());
49
49
  }
50
50
  }
51
+
52
+ /// Frees an array of C strings allocated by Rust.
53
+ ///
54
+ /// # Safety
55
+ /// - `ptr` must be a pointer to a boxed slice of C strings previously allocated by this crate.
56
+ /// - `count` must be the length of the array.
57
+ /// - `ptr` must not be used after being freed.
58
+ #[unsafe(no_mangle)]
59
+ pub unsafe extern "C" fn free_c_string_array(ptr: *const *const c_char, count: usize) {
60
+ if ptr.is_null() {
61
+ return;
62
+ }
63
+
64
+ let slice = unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr.cast_mut(), count)) };
65
+ let _: Vec<_> = slice
66
+ .iter()
67
+ .filter(|p| !p.is_null())
68
+ .map(|arg| unsafe { CString::from_raw((*arg).cast_mut()) })
69
+ .collect();
70
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubydex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta3
4
+ version: 0.1.0.beta4
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Shopify
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-06 00:00:00.000000000 Z
11
+ date: 2026-02-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A high performance static analysis suite for Ruby, built in Rust with
14
14
  Ruby APIs
@@ -76,6 +76,7 @@ files:
76
76
  - rust/rubydex/src/errors.rs
77
77
  - rust/rubydex/src/indexing.rs
78
78
  - rust/rubydex/src/indexing/local_graph.rs
79
+ - rust/rubydex/src/indexing/rbs_indexer.rs
79
80
  - rust/rubydex/src/indexing/ruby_indexer.rs
80
81
  - rust/rubydex/src/job_queue.rs
81
82
  - rust/rubydex/src/lib.rs
@@ -101,6 +102,7 @@ files:
101
102
  - rust/rubydex/src/resolution.rs
102
103
  - rust/rubydex/src/stats.rs
103
104
  - rust/rubydex/src/stats/memory.rs
105
+ - rust/rubydex/src/stats/orphan_report.rs
104
106
  - rust/rubydex/src/stats/timer.rs
105
107
  - rust/rubydex/src/test_utils.rs
106
108
  - rust/rubydex/src/test_utils/context.rs