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
@@ -17,6 +17,8 @@ VALUE cUnresolvedConstantReference;
17
17
  VALUE cResolvedConstantReference;
18
18
  VALUE cMethodReference;
19
19
 
20
+ static VALUE cDocument;
21
+
20
22
  /*
21
23
  * call-seq:
22
24
  * name -> String
@@ -28,6 +30,9 @@ static VALUE rdxr_constant_reference_name(VALUE self) {
28
30
  void *graph = rdxi_graph_from_handle(self, &data);
29
31
 
30
32
  const char *name = rdx_constant_reference_name(graph, data->id);
33
+ if (name == NULL) {
34
+ rb_raise(rb_eRuntimeError, "Constant reference must exist for a valid id");
35
+ }
31
36
  return rdxi_owned_c_string_to_ruby(name);
32
37
  }
33
38
 
@@ -42,11 +47,35 @@ static VALUE rdxr_constant_reference_location(VALUE self) {
42
47
  void *graph = rdxi_graph_from_handle(self, &data);
43
48
 
44
49
  Location *loc = rdx_constant_reference_location(graph, data->id);
50
+ if (loc == NULL) {
51
+ rb_raise(rb_eRuntimeError, "Constant reference must exist for a valid id");
52
+ }
45
53
  VALUE location = rdxi_build_location_value(loc);
46
54
  rdx_location_free(loc);
47
55
  return location;
48
56
  }
49
57
 
58
+ /*
59
+ * call-seq:
60
+ * document -> Rubydex::Document
61
+ *
62
+ * Returns the document this constant reference belongs to.
63
+ */
64
+ static VALUE rdxr_constant_reference_document(VALUE self) {
65
+ HandleData *data;
66
+ void *graph = rdxi_graph_from_handle(self, &data);
67
+
68
+ const uint64_t *uri_id = rdx_constant_reference_document(graph, data->id);
69
+ if (uri_id == NULL) {
70
+ rb_raise(rb_eRuntimeError, "Constant reference not found");
71
+ }
72
+
73
+ VALUE argv[] = {data->graph_obj, ULL2NUM(*uri_id)};
74
+ free_u64(uri_id);
75
+
76
+ return rb_class_new_instance(2, argv, cDocument);
77
+ }
78
+
50
79
  /*
51
80
  * call-seq:
52
81
  * name -> String
@@ -58,6 +87,9 @@ static VALUE rdxr_method_reference_name(VALUE self) {
58
87
  void *graph = rdxi_graph_from_handle(self, &data);
59
88
 
60
89
  const char *name = rdx_method_reference_name(graph, data->id);
90
+ if (name == NULL) {
91
+ rb_raise(rb_eRuntimeError, "Method reference must exist for a valid id");
92
+ }
61
93
  return rdxi_owned_c_string_to_ruby(name);
62
94
  }
63
95
 
@@ -72,6 +104,9 @@ static VALUE rdxr_method_reference_location(VALUE self) {
72
104
  void *graph = rdxi_graph_from_handle(self, &data);
73
105
 
74
106
  Location *loc = rdx_method_reference_location(graph, data->id);
107
+ if (loc == NULL) {
108
+ rb_raise(rb_eRuntimeError, "Method reference must exist for a valid id");
109
+ }
75
110
  VALUE location = rdxi_build_location_value(loc);
76
111
  rdx_location_free(loc);
77
112
  return location;
@@ -100,6 +135,27 @@ static VALUE rdxr_method_reference_receiver(VALUE self) {
100
135
  return rb_class_new_instance(2, argv, decl_class);
101
136
  }
102
137
 
138
+ /*
139
+ * call-seq:
140
+ * document -> Rubydex::Document
141
+ *
142
+ * Returns the document this method reference belongs to.
143
+ */
144
+ static VALUE rdxr_method_reference_document(VALUE self) {
145
+ HandleData *data;
146
+ void *graph = rdxi_graph_from_handle(self, &data);
147
+
148
+ const uint64_t *uri_id = rdx_method_reference_document(graph, data->id);
149
+ if (uri_id == NULL) {
150
+ rb_raise(rb_eRuntimeError, "Method reference not found");
151
+ }
152
+
153
+ VALUE argv[] = {data->graph_obj, ULL2NUM(*uri_id)};
154
+ free_u64(uri_id);
155
+
156
+ return rb_class_new_instance(2, argv, cDocument);
157
+ }
158
+
103
159
  /*
104
160
  * call-seq:
105
161
  * declaration -> Rubydex::Declaration
@@ -123,6 +179,8 @@ static VALUE rdxr_resolved_constant_reference_declaration(VALUE self) {
123
179
  }
124
180
 
125
181
  void rdxi_initialize_reference(VALUE mRubydex) {
182
+ cDocument = rb_const_get(mRubydex, rb_intern("Document"));
183
+
126
184
  cReference = rb_define_class_under(mRubydex, "Reference", rb_cObject);
127
185
  rb_define_alloc_func(cReference, rdxr_handle_alloc);
128
186
  rb_define_method(cReference, "initialize", rdxr_handle_initialize, 2);
@@ -132,6 +190,7 @@ void rdxi_initialize_reference(VALUE mRubydex) {
132
190
  rb_define_alloc_func(cConstantReference, rdxr_handle_alloc);
133
191
  rb_define_method(cConstantReference, "initialize", rdxr_handle_initialize, 2);
134
192
  rb_define_method(cConstantReference, "location", rdxr_constant_reference_location, 0);
193
+ rb_define_method(cConstantReference, "document", rdxr_constant_reference_document, 0);
135
194
  rb_funcall(rb_singleton_class(cConstantReference), rb_intern("private"), 1, ID2SYM(rb_intern("new")));
136
195
 
137
196
  cUnresolvedConstantReference = rb_define_class_under(mRubydex, "UnresolvedConstantReference", cConstantReference);
@@ -148,4 +207,5 @@ void rdxi_initialize_reference(VALUE mRubydex) {
148
207
  rb_define_method(cMethodReference, "name", rdxr_method_reference_name, 0);
149
208
  rb_define_method(cMethodReference, "location", rdxr_method_reference_location, 0);
150
209
  rb_define_method(cMethodReference, "receiver", rdxr_method_reference_receiver, 0);
210
+ rb_define_method(cMethodReference, "document", rdxr_method_reference_document, 0);
151
211
  }
@@ -4,6 +4,7 @@
4
4
  #include "document.h"
5
5
  #include "graph.h"
6
6
  #include "location.h"
7
+ #include "query.h"
7
8
  #include "reference.h"
8
9
  #include "signature.h"
9
10
 
@@ -19,6 +20,7 @@ void Init_rubydex(void) {
19
20
  */
20
21
  mRubydex = rb_define_module("Rubydex");
21
22
  rdxi_initialize_graph(mRubydex);
23
+ rdxi_initialize_query(mRubydex);
22
24
  rdxi_initialize_declaration(mRubydex);
23
25
  rdxi_initialize_document(mRubydex);
24
26
  rdxi_initialize_definition(mRubydex);
data/ext/rubydex/utils.c CHANGED
@@ -52,6 +52,18 @@ VALUE rdxi_owned_c_string_to_ruby(const char *string) {
52
52
  return value;
53
53
  }
54
54
 
55
+ // Coerce an optional String, Symbol, or nil to a C string, defaulting to `default_value` for nil.
56
+ const char *rdxi_symbol_or_string_cstr(VALUE value, const char *default_value) {
57
+ if (NIL_P(value)) {
58
+ return default_value;
59
+ }
60
+ if (RB_TYPE_P(value, T_SYMBOL)) {
61
+ value = rb_sym2str(value);
62
+ }
63
+ Check_Type(value, T_STRING);
64
+ return StringValueCStr(value);
65
+ }
66
+
55
67
  // Yield body for iterating over declarations
56
68
  VALUE rdxi_declarations_yield(VALUE args) {
57
69
  VALUE self = rb_ary_entry(args, 0);
data/ext/rubydex/utils.h CHANGED
@@ -17,6 +17,11 @@ void rdxi_check_array_of_strings(VALUE array);
17
17
  // Returns nil when the Rust side returned NULL.
18
18
  VALUE rdxi_owned_c_string_to_ruby(const char *string);
19
19
 
20
+ // Coerce an optional String, Symbol, or nil to a C string, returning `default_value` when the value
21
+ // is nil. A non-nil value must be a String or Symbol, otherwise a `TypeError` is raised. The
22
+ // returned pointer is owned by the Ruby VALUE and is only valid while it stays live.
23
+ const char *rdxi_symbol_or_string_cstr(VALUE value, const char *default_value);
24
+
20
25
  // Yield body for iterating over declarations
21
26
  VALUE rdxi_declarations_yield(VALUE args);
22
27
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubydex
4
- VERSION = "0.2.9"
4
+ VERSION = "0.3.0"
5
5
  end
data/rbi/rubydex.rbi CHANGED
@@ -20,6 +20,9 @@ class Rubydex::ConstantReference < Rubydex::Reference
20
20
  sig { returns(Rubydex::Location) }
21
21
  def location; end
22
22
 
23
+ sig { returns(Rubydex::Document) }
24
+ def document; end
25
+
23
26
  class << self
24
27
  private
25
28
 
@@ -158,6 +161,9 @@ class Rubydex::Definition
158
161
  sig { returns(T::Array[Rubydex::Definition]) }
159
162
  def lexical_nesting; end
160
163
 
164
+ sig { returns(Rubydex::Document) }
165
+ def document; end
166
+
161
167
  class << self
162
168
  private
163
169
 
@@ -278,6 +284,19 @@ end
278
284
 
279
285
  class Rubydex::IntegrityFailure < Rubydex::Failure; end
280
286
 
287
+ class Rubydex::Query
288
+ class << self
289
+ sig { params(query: String).returns(Rubydex::Query) }
290
+ def parse(query); end
291
+
292
+ sig { params(format: T.any(String, Symbol)).returns(String) }
293
+ def schema(format = :table); end
294
+ end
295
+
296
+ sig { params(graph: Rubydex::Graph, format: T.any(String, Symbol)).returns(String) }
297
+ def render(graph, format = :table); end
298
+ end
299
+
281
300
  class Rubydex::Graph
282
301
  sig { params(workspace_path: T.nilable(String)).void }
283
302
  def initialize(workspace_path: nil); end
@@ -502,6 +521,9 @@ class Rubydex::MethodReference < Rubydex::Reference
502
521
 
503
522
  sig { returns(T.nilable(Rubydex::Declaration)) }
504
523
  def receiver; end
524
+
525
+ sig { returns(Rubydex::Document) }
526
+ def document; end
505
527
  end
506
528
 
507
529
  class Rubydex::Reference
data/rust/Cargo.lock CHANGED
@@ -260,6 +260,12 @@ version = "0.8.21"
260
260
  source = "registry+https://github.com/rust-lang/crates.io-index"
261
261
  checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
262
262
 
263
+ [[package]]
264
+ name = "cypher-parser"
265
+ version = "0.2.0"
266
+ source = "registry+https://github.com/rust-lang/crates.io-index"
267
+ checksum = "fed0d1e561d51e651bdf70f8439da293fd0f1fe34d8431059061eadaefc7abb1"
268
+
263
269
  [[package]]
264
270
  name = "difflib"
265
271
  version = "0.4.0"
@@ -763,6 +769,7 @@ dependencies = [
763
769
  "crossbeam-channel",
764
770
  "crossbeam-deque",
765
771
  "crossbeam-utils",
772
+ "cypher-parser",
766
773
  "glob",
767
774
  "libc",
768
775
  "line-index",
@@ -27,6 +27,7 @@ jemalloc_dylib = ["jemalloc", "tikv-jemallocator/disable_initial_exec_tls"]
27
27
  test_utils = ["dep:tempfile"]
28
28
 
29
29
  [dependencies]
30
+ cypher-parser = "0.2"
30
31
  ruby-prism = "1.9.0"
31
32
  ruby-rbs = "0.3"
32
33
  url = "2.5.4"
@@ -1,6 +1,6 @@
1
1
  #[cfg(all(feature = "jemalloc", not(target_os = "windows")))]
2
2
  mod imp {
3
- use std::collections::HashSet;
3
+ use std::time::Instant;
4
4
 
5
5
  use rubydex::{
6
6
  indexing::{self, IndexerBackend},
@@ -18,13 +18,31 @@ mod imp {
18
18
  }
19
19
 
20
20
  pub fn run() {
21
- let paths: Vec<String> = std::env::args().skip(1).collect();
22
- let paths = if paths.is_empty() { vec![".".to_string()] } else { paths };
23
- let (file_paths, _) = listing::collect_file_paths(paths, &HashSet::new());
21
+ let mut args = std::env::args().skip(1);
22
+
23
+ let workspace = args
24
+ .next()
25
+ .expect("incorrect usage of cargo bench --bench graph_memory. Please use `utils/bench-graph-memory` instead of invoking this benchmark directly.");
26
+ let workspace_path = std::fs::canonicalize(&workspace).expect("the workspace path must exist");
27
+ assert!(workspace_path.is_dir(), "the workspace path must be a directory");
24
28
 
25
29
  let mut graph = Graph::new();
30
+ graph.set_workspace_path(workspace_path);
31
+
32
+ if let Err(error) = graph.load_config(None) {
33
+ eprintln!("{error}");
34
+ }
35
+
36
+ let time = Instant::now();
37
+
38
+ let (file_paths, _) = listing::collect_file_paths(args.collect(), &graph.excluded_patterns());
39
+ println!("Listing {:.2}s", time.elapsed().as_secs_f64());
40
+
26
41
  let _ = indexing::index_files(&mut graph, file_paths, IndexerBackend::RubyIndexer);
42
+ println!("Indexing {:.2}s", time.elapsed().as_secs_f64());
43
+
27
44
  Resolver::new(&mut graph).resolve();
45
+ println!("Resolution {:.2}s", time.elapsed().as_secs_f64());
28
46
 
29
47
  // Compare the total memory used in the allocator before and after dropping the graph
30
48
  let before_drop = allocated_bytes();
@@ -11,3 +11,18 @@ macro_rules! assert_mem_size {
11
11
  const _: [(); $size] = [(); std::mem::size_of::<$struct>()];
12
12
  };
13
13
  }
14
+
15
+ /// Asserts at compile time that a type is `Send + Sync`.
16
+ ///
17
+ /// The `Graph` is shared across Ractors/threads behind a `RwLock`, so it must stay
18
+ /// `Send + Sync`. This assertion fails the build if a field that breaks either trait
19
+ /// is ever added.
20
+ #[macro_export]
21
+ macro_rules! assert_send_sync {
22
+ ($struct:ident) => {
23
+ const _: fn() = || {
24
+ fn assert_send_sync<T: ?Sized + Send + Sync>() {}
25
+ assert_send_sync::<$struct>();
26
+ };
27
+ };
28
+ }
@@ -103,7 +103,7 @@ rules! {
103
103
  DynamicSingletonDefinition;
104
104
  DynamicAncestor;
105
105
  TopLevelMixinSelf;
106
- InvalidPrivateConstant;
106
+ InvalidConstantVisibility;
107
107
  InvalidMethodVisibility;
108
108
 
109
109
  // Resolution
@@ -80,7 +80,16 @@ impl<'a> RBSIndexer<'a> {
80
80
  let Node::Symbol(symbol) = path_node else {
81
81
  continue;
82
82
  };
83
- parent_scope = ParentScope::Some(self.intern_name(&symbol, parent_scope, nesting_name_id));
83
+ let name_id = self.intern_name(&symbol, parent_scope, nesting_name_id);
84
+
85
+ // Emit a constant reference for each parent-scope segment so it gets its own resolution
86
+ // unit, mirroring the Ruby indexer (see `index_constant_reference`). Otherwise the
87
+ // parent scope is an orphan name that never resolves, leaving qualified references stuck.
88
+ let offset = Offset::from_rbs_location(&symbol.location());
89
+ self.local_graph
90
+ .add_constant_reference(ConstantReference::new(name_id, self.uri_id, offset));
91
+
92
+ parent_scope = ParentScope::Some(name_id);
84
93
  }
85
94
 
86
95
  self.intern_name(&type_name.name(), parent_scope, nesting_name_id)
@@ -486,7 +495,10 @@ impl Visit for RBSIndexer<'_> {
486
495
  self.uri_id,
487
496
  offset,
488
497
  comments,
489
- Self::flags(&constant_node.annotations()),
498
+ // RBS establishes that the constant exists, but its value type is intentionally not
499
+ // represented in the graph. Treat it like a dynamic Ruby assignment so resolution
500
+ // may promote it when a namespace or singleton receiver is required.
501
+ Self::flags(&constant_node.annotations()) | DefinitionFlags::PROMOTABLE,
490
502
  lexical_nesting_id,
491
503
  )));
492
504
 
@@ -386,21 +386,8 @@ impl<'a> RubyIndexer<'a> {
386
386
  {
387
387
  if let Some(arguments) = node.arguments() {
388
388
  for argument in &arguments.arguments() {
389
- match argument {
390
- ruby_prism::Node::SymbolNode { .. } => {
391
- let symbol = argument.as_symbol_node().unwrap();
392
-
393
- if let Some(value_loc) = symbol.value_loc() {
394
- let name = Self::location_to_string(&value_loc);
395
- f(name, value_loc);
396
- }
397
- }
398
- ruby_prism::Node::StringNode { .. } => {
399
- let string = argument.as_string_node().unwrap();
400
- let name = String::from_utf8_lossy(string.unescaped()).to_string();
401
- f(name, argument.location());
402
- }
403
- _ => {}
389
+ if let Some((name, location)) = Self::extract_literal_name(&argument) {
390
+ f(name, location);
404
391
  }
405
392
  }
406
393
  }
@@ -1193,10 +1180,11 @@ impl<'a> RubyIndexer<'a> {
1193
1180
 
1194
1181
  fn handle_constant_visibility(&mut self, node: &ruby_prism::CallNode, visibility: Visibility) {
1195
1182
  let receiver = node.receiver();
1183
+ let call_name = String::from_utf8_lossy(node.name().as_slice());
1196
1184
 
1197
- let receiver_name_id = match receiver {
1185
+ let receiver_name_id = match &receiver {
1198
1186
  Some(ruby_prism::Node::ConstantPathNode { .. } | ruby_prism::Node::ConstantReadNode { .. }) => {
1199
- self.index_constant_reference(&receiver.unwrap(), true)
1187
+ self.index_constant_reference(receiver.as_ref().unwrap(), true)
1200
1188
  }
1201
1189
  Some(ruby_prism::Node::SelfNode { .. }) | None => match self.nesting_stack.last() {
1202
1190
  Some(Nesting::Method(_)) => {
@@ -1205,20 +1193,20 @@ impl<'a> RubyIndexer<'a> {
1205
1193
  }
1206
1194
  None => {
1207
1195
  self.local_graph.add_diagnostic(
1208
- Rule::InvalidPrivateConstant,
1196
+ Rule::InvalidConstantVisibility,
1209
1197
  Offset::from_prism_location(&node.location()),
1210
- "Private constant called at top level".to_string(),
1198
+ format!("`{call_name}` called at top level"),
1211
1199
  );
1212
1200
  self.visit_call_node_parts(node);
1213
1201
  return;
1214
1202
  }
1215
1203
  _ => None,
1216
1204
  },
1217
- _ => {
1205
+ Some(other) => {
1218
1206
  self.local_graph.add_diagnostic(
1219
- Rule::InvalidPrivateConstant,
1220
- Offset::from_prism_location(&node.location()),
1221
- "Dynamic receiver for private constant".to_string(),
1207
+ Rule::InvalidConstantVisibility,
1208
+ Offset::from_prism_location(&other.location()),
1209
+ format!("Dynamic receiver for `{call_name}`"),
1222
1210
  );
1223
1211
  self.visit_call_node_parts(node);
1224
1212
  return;
@@ -1230,29 +1218,14 @@ impl<'a> RubyIndexer<'a> {
1230
1218
  };
1231
1219
 
1232
1220
  for argument in &arguments.arguments() {
1233
- let (name, location) = match argument {
1234
- ruby_prism::Node::SymbolNode { .. } => {
1235
- let symbol = argument.as_symbol_node().unwrap();
1236
- if let Some(value_loc) = symbol.value_loc() {
1237
- (Self::location_to_string(&value_loc), value_loc)
1238
- } else {
1239
- continue;
1240
- }
1241
- }
1242
- ruby_prism::Node::StringNode { .. } => {
1243
- let string = argument.as_string_node().unwrap();
1244
- let name = String::from_utf8_lossy(string.unescaped()).to_string();
1245
- (name, argument.location())
1246
- }
1247
- _ => {
1248
- self.local_graph.add_diagnostic(
1249
- Rule::InvalidPrivateConstant,
1250
- Offset::from_prism_location(&argument.location()),
1251
- "Private constant called with non-symbol argument".to_string(),
1252
- );
1253
- self.visit(&argument);
1254
- continue;
1255
- }
1221
+ let Some((name, location)) = Self::extract_literal_name(&argument) else {
1222
+ self.local_graph.add_diagnostic(
1223
+ Rule::InvalidConstantVisibility,
1224
+ Offset::from_prism_location(&argument.location()),
1225
+ format!("`{call_name}` called with a non-literal argument"),
1226
+ );
1227
+ self.visit(&argument);
1228
+ continue;
1256
1229
  };
1257
1230
 
1258
1231
  let str_id = self.local_graph.intern_string(name);
@@ -1414,6 +1387,22 @@ impl<'a> RubyIndexer<'a> {
1414
1387
  }
1415
1388
  }
1416
1389
 
1390
+ fn extract_literal_name<'b>(arg: &ruby_prism::Node<'b>) -> Option<(String, ruby_prism::Location<'b>)> {
1391
+ match arg {
1392
+ ruby_prism::Node::SymbolNode { .. } => {
1393
+ let symbol = arg.as_symbol_node().unwrap();
1394
+ let value_loc = symbol.value_loc()?;
1395
+ Some((Self::location_to_string(&value_loc), value_loc))
1396
+ }
1397
+ ruby_prism::Node::StringNode { .. } => {
1398
+ let string = arg.as_string_node().unwrap();
1399
+ let name = String::from_utf8_lossy(string.unescaped()).to_string();
1400
+ Some((name, arg.location()))
1401
+ }
1402
+ _ => None,
1403
+ }
1404
+ }
1405
+
1417
1406
  fn is_attr_call(arg: &ruby_prism::Node) -> bool {
1418
1407
  arg.as_call_node().is_some_and(|call| {
1419
1408
  let receiver = call.receiver();
@@ -1452,6 +1441,18 @@ impl<'a> RubyIndexer<'a> {
1452
1441
  ruby_prism::Node::SymbolNode { .. } | ruby_prism::Node::StringNode { .. }
1453
1442
  ) {
1454
1443
  self.create_method_visibility_definition(&arg, visibility, DefinitionFlags::empty());
1444
+ if visibility == Visibility::ModuleFunction {
1445
+ // `module_function` also creates a public singleton method, so we emit a
1446
+ // second def for the singleton side. The two defs stay separate (rather than
1447
+ // shared) so reverse-lookup invalidation can detach `Foo#bar` and
1448
+ // `Foo::<Foo>#bar` independently. The `SINGLETON_METHOD_VISIBILITY` flag
1449
+ // routes this one to the singleton class.
1450
+ self.create_method_visibility_definition(
1451
+ &arg,
1452
+ visibility,
1453
+ DefinitionFlags::SINGLETON_METHOD_VISIBILITY,
1454
+ );
1455
+ }
1455
1456
  } else {
1456
1457
  // Unsupported arg — diagnostic + visit for side effects.
1457
1458
  let arg_offset = Offset::from_prism_location(&arg.location());
@@ -1473,18 +1474,8 @@ impl<'a> RubyIndexer<'a> {
1473
1474
  visibility: Visibility,
1474
1475
  flags: DefinitionFlags,
1475
1476
  ) {
1476
- let (name, location) = match arg {
1477
- ruby_prism::Node::SymbolNode { .. } => {
1478
- let symbol = arg.as_symbol_node().unwrap();
1479
- let Some(value_loc) = symbol.value_loc() else { return };
1480
- (Self::location_to_string(&value_loc), value_loc)
1481
- }
1482
- ruby_prism::Node::StringNode { .. } => {
1483
- let string = arg.as_string_node().unwrap();
1484
- let name = String::from_utf8_lossy(string.unescaped()).to_string();
1485
- (name, arg.location())
1486
- }
1487
- _ => return,
1477
+ let Some((name, location)) = Self::extract_literal_name(arg) else {
1478
+ return;
1488
1479
  };
1489
1480
 
1490
1481
  self.create_method_visibility_definition_from_name(&name, &location, visibility, flags);
@@ -1750,20 +1750,20 @@ mod visibility_tests {
1750
1750
  fn index_private_constant_calls_diagnostics() {
1751
1751
  let context = index_source({
1752
1752
  "
1753
- private_constant :NOT_INDEXED
1754
- self.private_constant :NOT_INDEXED
1755
- foo.private_constant :NOT_INDEXED # not indexed, dynamic receiver
1753
+ private_constant :BAR # not indexed, called at top level
1754
+ self.private_constant :BAR # not indexed, self receiver at top level
1755
+ foo.private_constant :BAR # not indexed, dynamic receiver
1756
1756
 
1757
1757
  module Foo
1758
- private_constant NOT_INDEXED, not_indexed # not indexed, not a symbol
1758
+ private_constant SomeConst, some_method # not indexed, non-literal arguments
1759
1759
  private_constant # not indexed, no arguments
1760
1760
 
1761
1761
  def self.qux
1762
- private_constant :Bar # not indexed, dynamic
1762
+ private_constant :BAR # not indexed, inside a method
1763
1763
  end
1764
1764
 
1765
1765
  def foo
1766
- private_constant :Bar # not indexed, dynamic
1766
+ private_constant :BAR # not indexed, inside a method
1767
1767
  end
1768
1768
  end
1769
1769
  "
@@ -1772,15 +1772,52 @@ mod visibility_tests {
1772
1772
  assert_local_diagnostics_eq!(
1773
1773
  &context,
1774
1774
  vec![
1775
- "invalid-private-constant: Private constant called at top level (1:1-1:30)",
1776
- "invalid-private-constant: Private constant called at top level (2:1-2:35)",
1777
- "invalid-private-constant: Dynamic receiver for private constant (3:1-3:34)",
1778
- "invalid-private-constant: Private constant called with non-symbol argument (6:20-6:31)",
1779
- "invalid-private-constant: Private constant called with non-symbol argument (6:33-6:44)",
1775
+ "invalid-constant-visibility: `private_constant` called at top level (1:1-1:22)",
1776
+ "invalid-constant-visibility: `private_constant` called at top level (2:1-2:27)",
1777
+ "invalid-constant-visibility: Dynamic receiver for `private_constant` (3:1-3:4)",
1778
+ "invalid-constant-visibility: `private_constant` called with a non-literal argument (6:20-6:29)",
1779
+ "invalid-constant-visibility: `private_constant` called with a non-literal argument (6:31-6:42)",
1780
1780
  ]
1781
1781
  );
1782
1782
 
1783
- assert_eq!(context.graph().definitions().len(), 3); // Foo, Foo::Qux, Foo#foo
1783
+ assert_eq!(context.graph().definitions().len(), 3); // Foo, Foo.qux, Foo#foo
1784
+ }
1785
+
1786
+ #[test]
1787
+ fn index_public_constant_calls_diagnostics() {
1788
+ let context = index_source({
1789
+ "
1790
+ public_constant :BAR # not indexed, called at top level
1791
+ self.public_constant :BAR # not indexed, self receiver at top level
1792
+ foo.public_constant :BAR # not indexed, dynamic receiver
1793
+
1794
+ module Foo
1795
+ public_constant SomeConst, some_method # not indexed, non-literal arguments
1796
+ public_constant # not indexed, no arguments
1797
+
1798
+ def self.qux
1799
+ public_constant :BAR # not indexed, inside a method
1800
+ end
1801
+
1802
+ def foo
1803
+ public_constant :BAR # not indexed, inside a method
1804
+ end
1805
+ end
1806
+ "
1807
+ });
1808
+
1809
+ assert_local_diagnostics_eq!(
1810
+ &context,
1811
+ vec![
1812
+ "invalid-constant-visibility: `public_constant` called at top level (1:1-1:21)",
1813
+ "invalid-constant-visibility: `public_constant` called at top level (2:1-2:26)",
1814
+ "invalid-constant-visibility: Dynamic receiver for `public_constant` (3:1-3:4)",
1815
+ "invalid-constant-visibility: `public_constant` called with a non-literal argument (6:19-6:28)",
1816
+ "invalid-constant-visibility: `public_constant` called with a non-literal argument (6:30-6:41)",
1817
+ ]
1818
+ );
1819
+
1820
+ assert_eq!(context.graph().definitions().len(), 3); // Foo, Foo.qux, Foo#foo
1784
1821
  }
1785
1822
 
1786
1823
  #[test]
@@ -2021,10 +2058,29 @@ mod visibility_tests {
2021
2058
 
2022
2059
  assert_no_local_diagnostics!(&context);
2023
2060
 
2024
- assert_definition_at!(&context, "4:20-4:23", MethodVisibility, |def| {
2025
- assert_def_str_eq!(&context, def, "foo()");
2026
- assert_eq!(def.visibility(), &Visibility::ModuleFunction);
2027
- });
2061
+ let defs = context.all_definitions_at("4:20-4:23");
2062
+ assert_eq!(defs.len(), 2, "expected two MethodVisibility defs");
2063
+
2064
+ let mut instance_side = None;
2065
+ let mut singleton_side = None;
2066
+ for def in defs {
2067
+ let Definition::MethodVisibility(method_vis) = def else {
2068
+ panic!("expected MethodVisibility, got {:?}", def.kind());
2069
+ };
2070
+ if method_vis.flags().is_singleton_method_visibility() {
2071
+ singleton_side = Some(method_vis.as_ref());
2072
+ } else {
2073
+ instance_side = Some(method_vis.as_ref());
2074
+ }
2075
+ }
2076
+
2077
+ let instance_side = instance_side.expect("missing instance-side def");
2078
+ assert_def_str_eq!(&context, instance_side, "foo()");
2079
+ assert_eq!(instance_side.visibility(), &Visibility::ModuleFunction);
2080
+
2081
+ let singleton_side = singleton_side.expect("missing singleton-side def");
2082
+ assert_def_str_eq!(&context, singleton_side, "foo()");
2083
+ assert_eq!(singleton_side.visibility(), &Visibility::ModuleFunction);
2028
2084
  }
2029
2085
 
2030
2086
  #[test]