diff --git a/rust/rubydex-sys/src/graph_api.rs b/rust/rubydex-sys/src/graph_api.rs index 7b8ea4a4a..598b87e2f 100644 --- a/rust/rubydex-sys/src/graph_api.rs +++ b/rust/rubydex-sys/src/graph_api.rs @@ -428,7 +428,10 @@ pub unsafe extern "C" fn rdx_graph_get_declaration(pointer: GraphPointer, name: }; with_graph(pointer, |graph| { - let decl_id = DeclarationId::from(name_str.as_str()); + // Accept an optional leading `::` root-scope marker so `"::Object"` and `"Object"` + // resolve to the same declaration. All stored FQNs are implicitly root-scoped. + let lookup_name = name_str.strip_prefix("::").unwrap_or(name_str.as_str()); + let decl_id = DeclarationId::from(lookup_name); if let Some(decl) = graph.declarations().get(&decl_id) { Box::into_raw(Box::new(CDeclaration::from_declaration(decl_id, decl))).cast_const() diff --git a/rust/rubydex/src/model/graph.rs b/rust/rubydex/src/model/graph.rs index 469f84c44..f4df6bcfd 100644 --- a/rust/rubydex/src/model/graph.rs +++ b/rust/rubydex/src/model/graph.rs @@ -549,6 +549,9 @@ impl Graph { #[must_use] pub fn get(&self, name: &str) -> Option> { + // Accept an optional leading `::` root-scope marker so `"::Object"` and `"Object"` + // resolve to the same declaration. All stored FQNs are implicitly root-scoped. + let name = name.strip_prefix("::").unwrap_or(name); let declaration_id = DeclarationId::from(name); let declaration = self.declarations.get(&declaration_id)?; @@ -1931,6 +1934,26 @@ mod tests { assert_eq!(definitions[0].offset().start(), 6); } + #[test] + fn get_accepts_leading_double_colon() { + let mut context = GraphTest::new(); + + context.index_uri("file:///foo.rb", "module Foo; module Bar; end; end"); + context.resolve(); + + // Built-in declarations (root-scoped): + let unqualified = context.graph().get("Object").expect("unqualified `Object` lookup"); + let qualified = context.graph().get("::Object").expect("qualified `::Object` lookup"); + assert_eq!(unqualified.len(), qualified.len()); + + // Indexed declarations, top-level and nested: + assert!(context.graph().get("::Foo").is_some()); + assert!(context.graph().get("::Foo::Bar").is_some()); + + // Unknown names still return None when prefixed: + assert!(context.graph().get("::DoesNotExist").is_none()); + } + #[test] fn adding_another_definition_from_a_different_uri() { let mut context = GraphTest::new(); diff --git a/test/graph_test.rb b/test/graph_test.rb index ad485b670..f1b0e9183 100644 --- a/test/graph_test.rb +++ b/test/graph_test.rb @@ -81,6 +81,28 @@ def test_graph_get_declaration end end + def test_graph_get_declaration_accepts_leading_double_colon + with_context do |context| + context.write!("file.rb", "module Foo; class Bar; end; end") + + graph = Rubydex::Graph.new + graph.index_all(context.glob("**/*.rb")) + graph.resolve + + # Built-ins are root-scoped, so `Object` and `::Object` must resolve to the same declaration. + refute_nil(graph["::Object"]) + assert_equal(graph["Object"].name, graph["::Object"].name) + + # Same for indexed declarations, top-level and nested. + refute_nil(graph["::Foo"]) + refute_nil(graph["::Foo::Bar"]) + assert_equal(graph["Foo::Bar"].name, graph["::Foo::Bar"].name) + + # Unknown names still return nil when prefixed. + assert_nil(graph["::DoesNotExist"]) + end + end + def test_list_all_declarations_enumerator with_context do |context| context.write!("file1.rb", "class A; end")