rubydex 0.2.8 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -3
  3. data/THIRD_PARTY_LICENSES.html +168 -1381
  4. data/exe/rdx +136 -34
  5. data/ext/rubydex/declaration.c +1 -1
  6. data/ext/rubydex/definition.c +32 -4
  7. data/ext/rubydex/extconf.rb +0 -8
  8. data/ext/rubydex/graph.c +29 -18
  9. data/ext/rubydex/query.c +105 -0
  10. data/ext/rubydex/query.h +8 -0
  11. data/ext/rubydex/reference.c +60 -0
  12. data/ext/rubydex/rubydex.c +2 -0
  13. data/ext/rubydex/utils.c +12 -0
  14. data/ext/rubydex/utils.h +5 -0
  15. data/lib/rubydex/mcp_server/protocol.rb +156 -0
  16. data/lib/rubydex/mcp_server/tools/base_tool.rb +109 -0
  17. data/lib/rubydex/mcp_server/tools/codebase_stats_tool.rb +30 -0
  18. data/lib/rubydex/mcp_server/tools/find_constant_references_tool.rb +46 -0
  19. data/lib/rubydex/mcp_server/tools/get_declaration_tool.rb +68 -0
  20. data/lib/rubydex/mcp_server/tools/get_descendants_tool.rb +46 -0
  21. data/lib/rubydex/mcp_server/tools/get_file_declarations_tool.rb +55 -0
  22. data/lib/rubydex/mcp_server/tools/search_declarations_tool.rb +55 -0
  23. data/lib/rubydex/mcp_server.rb +219 -0
  24. data/lib/rubydex/version.rb +1 -1
  25. data/rbi/rubydex.rbi +26 -4
  26. data/rust/Cargo.lock +5 -552
  27. data/rust/Cargo.toml +0 -1
  28. data/rust/rubydex/Cargo.toml +1 -0
  29. data/rust/rubydex/benches/graph_memory.rs +22 -4
  30. data/rust/rubydex/src/compile_assertions.rs +15 -0
  31. data/rust/rubydex/src/config.rs +54 -34
  32. data/rust/rubydex/src/diagnostic.rs +1 -1
  33. data/rust/rubydex/src/indexing/rbs_indexer.rs +14 -2
  34. data/rust/rubydex/src/indexing/ruby_indexer.rs +49 -58
  35. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +72 -16
  36. data/rust/rubydex/src/listing.rs +159 -102
  37. data/rust/rubydex/src/main.rs +3 -125
  38. data/rust/rubydex/src/model/declaration.rs +0 -11
  39. data/rust/rubydex/src/model/definitions.rs +27 -26
  40. data/rust/rubydex/src/model/document.rs +43 -7
  41. data/rust/rubydex/src/model/graph.rs +47 -35
  42. data/rust/rubydex/src/model/id.rs +55 -0
  43. data/rust/rubydex/src/model/ids.rs +21 -9
  44. data/rust/rubydex/src/model/name.rs +35 -7
  45. data/rust/rubydex/src/model/references.rs +16 -13
  46. data/rust/rubydex/src/operation/applier.rs +0 -2
  47. data/rust/rubydex/src/operation/ruby_builder.rs +48 -59
  48. data/rust/rubydex/src/query/cypher/schema.rs +790 -0
  49. data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
  50. data/rust/rubydex/src/query/cypher/tests.rs +228 -0
  51. data/rust/rubydex/src/query/cypher.rs +57 -0
  52. data/rust/rubydex/src/query.rs +2 -0
  53. data/rust/rubydex/src/resolution.rs +248 -227
  54. data/rust/rubydex/src/resolution_tests.rs +263 -65
  55. data/rust/rubydex-sys/src/declaration_api.rs +6 -3
  56. data/rust/rubydex-sys/src/definition_api.rs +27 -7
  57. data/rust/rubydex-sys/src/graph_api.rs +175 -27
  58. data/rust/rubydex-sys/src/reference_api.rs +58 -12
  59. metadata +17 -10
  60. data/exe/rubydex_mcp +0 -17
  61. data/lib/rubydex/bin/rubydex_mcp.exe +0 -0
  62. data/rust/rubydex-mcp/Cargo.toml +0 -34
  63. data/rust/rubydex-mcp/src/main.rs +0 -48
  64. data/rust/rubydex-mcp/src/server.rs +0 -1148
  65. data/rust/rubydex-mcp/src/tools.rs +0 -49
  66. data/rust/rubydex-mcp/tests/mcp.rs +0 -302
data/rust/Cargo.toml CHANGED
@@ -1,7 +1,6 @@
1
1
  [workspace]
2
2
  members = [
3
3
  "rubydex",
4
- "rubydex-mcp",
5
4
  "rubydex-sys",
6
5
  ]
7
6
 
@@ -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
+ }
@@ -21,9 +21,9 @@ pub const DEFAULT_EXCLUDED_DIRECTORIES: &[&str] = &[
21
21
  /// Configuration coming from a config file
22
22
  #[derive(Debug, Default, Deserialize)]
23
23
  struct ConfigFile {
24
- /// Paths to exclude from file discovery during indexing.
24
+ /// Patterns to exclude from file discovery during indexing.
25
25
  #[serde(default)]
26
- exclude: Vec<PathBuf>,
26
+ exclude: Vec<Box<str>>,
27
27
  }
28
28
 
29
29
  /// Project configuration
@@ -31,8 +31,8 @@ struct ConfigFile {
31
31
  pub struct Config {
32
32
  /// Path to the workspace being analyzed
33
33
  workspace_path: Box<Path>,
34
- /// Paths to exclude from file discovery during indexing.
35
- excluded_paths: HashSet<PathBuf>,
34
+ /// Patterns to exclude from file discovery during indexing.
35
+ excluded_patterns: HashSet<Box<str>>,
36
36
  }
37
37
  assert_mem_size!(Config, 64);
38
38
 
@@ -50,7 +50,7 @@ impl Config {
50
50
  workspace_path: std::env::current_dir()
51
51
  .unwrap_or_else(|_| PathBuf::from("."))
52
52
  .into_boxed_path(),
53
- excluded_paths: DEFAULT_EXCLUDED_DIRECTORIES.iter().map(PathBuf::from).collect(),
53
+ excluded_patterns: DEFAULT_EXCLUDED_DIRECTORIES.iter().map(|&dir| Box::from(dir)).collect(),
54
54
  }
55
55
  }
56
56
 
@@ -65,18 +65,24 @@ impl Config {
65
65
  self.workspace_path = workspace_path.into_boxed_path();
66
66
  }
67
67
 
68
- /// Adds paths to exclude from file discovery during indexing. Excluded directories will be skipped entirely during
68
+ /// Adds patterns to exclude from file discovery during indexing. Excluded directories will be skipped entirely during
69
69
  /// directory traversal.
70
- pub fn exclude_paths(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
71
- self.excluded_paths.extend(paths);
70
+ pub fn exclude_patterns(&mut self, patterns: impl IntoIterator<Item = Box<str>>) {
71
+ self.excluded_patterns.extend(patterns);
72
72
  }
73
73
 
74
- /// Returns the set of paths excluded from file discovery
74
+ /// Returns the set of exclusion patterns resolved against the workspace path.
75
75
  #[must_use]
76
- pub fn excluded_paths(&self) -> HashSet<PathBuf> {
77
- self.excluded_paths
76
+ pub fn excluded_patterns(&self) -> HashSet<Box<str>> {
77
+ self.excluded_patterns
78
78
  .iter()
79
- .map(|path| self.workspace_path.join(path))
79
+ .map(|entry| {
80
+ self.workspace_path
81
+ .join(&**entry)
82
+ .to_string_lossy()
83
+ .into_owned()
84
+ .into_boxed_str()
85
+ })
80
86
  .collect()
81
87
  }
82
88
 
@@ -124,7 +130,7 @@ impl Config {
124
130
  Errors::ConfigError(format!("Invalid config file `{}`: {error}", config_path.display()))
125
131
  })?;
126
132
 
127
- self.excluded_paths.extend(parsed.exclude);
133
+ self.excluded_patterns.extend(parsed.exclude);
128
134
  Ok(())
129
135
  }
130
136
 
@@ -139,19 +145,32 @@ mod tests {
139
145
  use super::*;
140
146
  use std::path::Path;
141
147
 
148
+ fn workspace_exclusion(entry: &str) -> String {
149
+ PathBuf::from("/workspace").join(entry).to_string_lossy().into_owned()
150
+ }
151
+
142
152
  #[test]
143
- fn excluded_paths_are_resolved_against_the_workspace_path() {
153
+ fn excluded_patterns_resolves_patterns_against_the_workspace_path() {
144
154
  let mut config = Config::new();
145
155
  config.set_workspace_path(PathBuf::from("/workspace"));
146
- config.exclude_paths([PathBuf::from("vendor"), PathBuf::from("/absolute/path")]);
147
-
148
- let excluded = config.excluded_paths();
149
-
150
- // Relative entries (including the defaults) are joined with the workspace path.
151
- assert!(excluded.contains(Path::new("/workspace/vendor")));
152
- assert!(excluded.contains(Path::new("/workspace/.git")));
153
- // Absolute entries pass through unchanged.
154
- assert!(excluded.contains(Path::new("/absolute/path")));
156
+ config.exclude_patterns([
157
+ Box::from("vendor"),
158
+ Box::from("**/fixtures"),
159
+ Box::from("/absolute/path"),
160
+ ]);
161
+
162
+ let excluded = config.excluded_patterns();
163
+
164
+ let vendor = workspace_exclusion("vendor");
165
+ let fixtures = workspace_exclusion("**/fixtures");
166
+ let absolute = PathBuf::from("/absolute/path").to_string_lossy().into_owned();
167
+ let git = workspace_exclusion(".git");
168
+
169
+ assert!(excluded.contains(vendor.as_str()));
170
+ assert!(excluded.contains(fixtures.as_str()));
171
+ assert!(excluded.contains(absolute.as_str()));
172
+ // Defaults are included and resolved as well.
173
+ assert!(excluded.contains(git.as_str()));
155
174
  }
156
175
 
157
176
  #[test]
@@ -160,14 +179,14 @@ mod tests {
160
179
 
161
180
  for default in DEFAULT_EXCLUDED_DIRECTORIES {
162
181
  assert!(
163
- config.excluded_paths.contains(Path::new(default)),
182
+ config.excluded_patterns.contains(*default),
164
183
  "expected `{default}` to be excluded by default"
165
184
  );
166
185
  }
167
186
  }
168
187
 
169
188
  #[test]
170
- fn load_file_merges_excluded_paths_and_leaves_the_workspace_path_untouched() {
189
+ fn load_file_merges_excluded_patterns_and_leaves_the_workspace_path_untouched() {
171
190
  let dir = tempfile::tempdir().expect("failed to create temp dir");
172
191
  let config_path = dir.path().join("rubydex.toml");
173
192
  fs::write(&config_path, "exclude = [\"vendor\", \"generated\"]\n").unwrap();
@@ -179,12 +198,12 @@ mod tests {
179
198
  .load_file(&config_path)
180
199
  .expect("expected the config file to load");
181
200
 
182
- let excluded = config.excluded_paths();
201
+ let excluded = config.excluded_patterns();
183
202
  // Entries from the file are merged in and resolved against the workspace path.
184
- assert!(excluded.contains(Path::new("/workspace/vendor")));
185
- assert!(excluded.contains(Path::new("/workspace/generated")));
203
+ assert!(excluded.contains(workspace_exclusion("vendor").as_str()));
204
+ assert!(excluded.contains(workspace_exclusion("generated").as_str()));
186
205
  // Defaults seeded at construction survive the merge.
187
- assert!(excluded.contains(Path::new("/workspace/node_modules")));
206
+ assert!(excluded.contains(workspace_exclusion("node_modules").as_str()));
188
207
  // A config file cannot override the programmatically-set workspace path.
189
208
  assert_eq!(config.workspace_path(), Path::new("/workspace"));
190
209
  }
@@ -204,9 +223,9 @@ mod tests {
204
223
  .load_file(&dir.path().join("b.toml"))
205
224
  .expect("expected the second file to load");
206
225
 
207
- let excluded = config.excluded_paths();
208
- assert!(excluded.contains(Path::new("/workspace/vendor")));
209
- assert!(excluded.contains(Path::new("/workspace/generated")));
226
+ let excluded = config.excluded_patterns();
227
+ assert!(excluded.contains(workspace_exclusion("vendor").as_str()));
228
+ assert!(excluded.contains(workspace_exclusion("generated").as_str()));
210
229
  }
211
230
 
212
231
  #[test]
@@ -244,7 +263,8 @@ mod tests {
244
263
  config.set_workspace_path(dir.path().to_path_buf());
245
264
  config.load_default().expect("expected rubydex.toml to load");
246
265
 
247
- assert!(config.excluded_paths().contains(&dir.path().join("vendor")));
266
+ let expected = dir.path().join("vendor").to_string_lossy().into_owned();
267
+ assert!(config.excluded_patterns().contains(expected.as_str()));
248
268
  }
249
269
 
250
270
  #[test]
@@ -263,7 +283,7 @@ mod tests {
263
283
  }
264
284
 
265
285
  #[test]
266
- fn parse_defaults_the_excluded_paths_to_empty_when_the_key_is_absent() {
286
+ fn parse_defaults_the_excluded_patterns_to_empty_when_the_key_is_absent() {
267
287
  let file = Config::parse("").expect("an empty config is valid");
268
288
  assert!(file.exclude.is_empty());
269
289
  }
@@ -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]