diff --git a/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb b/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb index 8a21afa26..5faa27e3c 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb @@ -33,6 +33,8 @@ def initialize(index, dispatcher, parse_result, file_path) :on_class_node_leave, :on_module_node_enter, :on_module_node_leave, + :on_singleton_class_node_enter, + :on_singleton_class_node_leave, :on_def_node_enter, :on_def_node_leave, :on_call_node_enter, @@ -115,6 +117,37 @@ def on_module_node_leave(node) @visibility_stack.pop end + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_enter(node) + @visibility_stack.push(Entry::Visibility::PUBLIC) + + current_owner = @owner_stack.last + + if current_owner + expression = node.expression + @stack << (expression.is_a?(Prism::SelfNode) ? "" : "") + + existing_entries = T.cast(@index[@stack.join("::")], T.nilable(T::Array[Entry::SingletonClass])) + + if existing_entries + entry = T.must(existing_entries.first) + entry.update_singleton_information(node.location, collect_comments(node)) + else + entry = Entry::SingletonClass.new(@stack, @file_path, node.location, collect_comments(node), nil) + @index << entry + end + + @owner_stack << entry + end + end + + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_leave(node) + @stack.pop + @owner_stack.pop + @visibility_stack.pop + end + sig { params(node: Prism::MultiWriteNode).void } def on_multi_write_node_enter(node) value = node.value @@ -246,7 +279,7 @@ def on_def_node_enter(node) case node.receiver when nil - @index << Entry::InstanceMethod.new( + @index << Entry::Method.new( method_name, @file_path, node.location, @@ -256,14 +289,14 @@ def on_def_node_enter(node) @owner_stack.last, ) when Prism::SelfNode - @index << Entry::SingletonMethod.new( + @index << Entry::Method.new( method_name, @file_path, node.location, comments, node.parameters, current_visibility, - @owner_stack.last, + singleton_klass, ) end end @@ -275,72 +308,27 @@ def on_def_node_leave(node) sig { params(node: Prism::InstanceVariableWriteNode).void } def on_instance_variable_write_node_enter(node) - name = node.name.to_s - return if name == "@" - - @index << Entry::InstanceVariable.new( - name, - @file_path, - node.name_loc, - collect_comments(node), - @owner_stack.last, - ) + handle_instance_variable(node, node.name_loc) end sig { params(node: Prism::InstanceVariableAndWriteNode).void } def on_instance_variable_and_write_node_enter(node) - name = node.name.to_s - return if name == "@" - - @index << Entry::InstanceVariable.new( - name, - @file_path, - node.name_loc, - collect_comments(node), - @owner_stack.last, - ) + handle_instance_variable(node, node.name_loc) end sig { params(node: Prism::InstanceVariableOperatorWriteNode).void } def on_instance_variable_operator_write_node_enter(node) - name = node.name.to_s - return if name == "@" - - @index << Entry::InstanceVariable.new( - name, - @file_path, - node.name_loc, - collect_comments(node), - @owner_stack.last, - ) + handle_instance_variable(node, node.name_loc) end sig { params(node: Prism::InstanceVariableOrWriteNode).void } def on_instance_variable_or_write_node_enter(node) - name = node.name.to_s - return if name == "@" - - @index << Entry::InstanceVariable.new( - name, - @file_path, - node.name_loc, - collect_comments(node), - @owner_stack.last, - ) + handle_instance_variable(node, node.name_loc) end sig { params(node: Prism::InstanceVariableTargetNode).void } def on_instance_variable_target_node_enter(node) - name = node.name.to_s - return if name == "@" - - @index << Entry::InstanceVariable.new( - name, - @file_path, - node.location, - collect_comments(node), - @owner_stack.last, - ) + handle_instance_variable(node, node.location) end sig { params(node: Prism::AliasMethodNode).void } @@ -359,6 +347,28 @@ def on_alias_method_node_enter(node) private + sig do + params( + node: T.any( + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableTargetNode, + Prism::InstanceVariableWriteNode, + ), + loc: Prism::Location, + ).void + end + def handle_instance_variable(node, loc) + name = node.name.to_s + return if name == "@" + + # When instance variables are declared inside the class body, they turn into class instance variables rather than + # regular instance variables + owner = @inside_def ? @owner_stack.last : singleton_klass + @index << Entry::InstanceVariable.new(name, @file_path, loc, collect_comments(node), owner) + end + sig { params(node: Prism::CallNode).void } def handle_private_constant(node) arguments = node.arguments&.arguments @@ -558,5 +568,24 @@ def handle_module_operation(node, operation) def current_visibility T.must(@visibility_stack.last) end + + sig { returns(T.nilable(Entry::Class)) } + def singleton_klass + attached_class = @owner_stack.last + return unless attached_class + + # Return the existing singleton class if available + owner = T.cast( + @index["#{attached_class.name}::"], + T.nilable(T::Array[Entry::SingletonClass]), + ) + return owner.first if owner + + # If not available, create the singleton class lazily + nesting = @stack + [""] + entry = Entry::SingletonClass.new(nesting, @file_path, attached_class.location, [], nil) + @index << entry + entry + end end end diff --git a/lib/ruby_indexer/lib/ruby_indexer/entry.rb b/lib/ruby_indexer/lib/ruby_indexer/entry.rb index 8ae271712..89eb25e7b 100644 --- a/lib/ruby_indexer/lib/ruby_indexer/entry.rb +++ b/lib/ruby_indexer/lib/ruby_indexer/entry.rb @@ -162,6 +162,22 @@ def ancestor_hash end end + class SingletonClass < Class + extend T::Sig + + sig { params(location: Prism::Location, comments: T::Array[String]).void } + def update_singleton_information(location, comments) + # Create a new RubyIndexer::Location object from the Prism location + @location = Location.new( + location.start_line, + location.end_line, + location.start_column, + location.end_column, + ) + @comments.concat(comments) + end + end + class Constant < Entry end @@ -280,9 +296,6 @@ def parameters class Method < Member extend T::Sig - extend T::Helpers - - abstract! sig { override.returns(T::Array[Parameter]) } attr_reader :parameters @@ -391,12 +404,6 @@ def parameter_name(node) end end - class SingletonMethod < Method - end - - class InstanceMethod < Method - end - # An UnresolvedAlias points to a constant alias with a right hand side that has not yet been resolved. For # example, if we find # diff --git a/lib/ruby_indexer/test/classes_and_modules_test.rb b/lib/ruby_indexer/test/classes_and_modules_test.rb index 877726083..ff3a17cb3 100644 --- a/lib/ruby_indexer/test/classes_and_modules_test.rb +++ b/lib/ruby_indexer/test/classes_and_modules_test.rb @@ -470,5 +470,51 @@ class ConstantPathReferences constant_path_references = T.must(@index["ConstantPathReferences"][0]) assert_equal(["Foo::Bar", "Foo::Bar2"], constant_path_references.mixin_operation_module_names) end + + def test_tracking_singleton_classes + index(<<~RUBY) + class Foo; end + class Foo + # Some extra comments + class << self + end + end + RUBY + + foo = T.must(@index["Foo::"].first) + assert_equal(4, foo.location.start_line) + assert_equal("Some extra comments", foo.comments.join("\n")) + end + + def test_dynamic_singleton_class_blocks + index(<<~RUBY) + class Foo + # Some extra comments + class << bar + end + end + RUBY + + singleton = T.must(@index["Foo::"].first) + + # Even though this is not correct, we consider any dynamic singleton class block as a regular singleton class. + # That pattern cannot be properly analyzed statically and assuming that it's always a regular singleton simplifies + # the implementation considerably. + assert_equal(3, singleton.location.start_line) + assert_equal("Some extra comments", singleton.comments.join("\n")) + end + + def test_namespaces_inside_singleton_blocks + index(<<~RUBY) + class Foo + class << self + class Bar + end + end + end + RUBY + + assert_entry("Foo::::Bar", Entry::Class, "/fake/path/foo.rb:2-4:3-7") + end end end diff --git a/lib/ruby_indexer/test/index_test.rb b/lib/ruby_indexer/test/index_test.rb index 5809cf837..9f3622ee8 100644 --- a/lib/ruby_indexer/test/index_test.rb +++ b/lib/ruby_indexer/test/index_test.rb @@ -1173,5 +1173,108 @@ class Bar < ::Object; end assert_equal(["Foo::Bar", "Object", "Kernel", "BasicObject"], @index.linearized_ancestors_of("Foo::Bar")) end + + def test_resolving_method_inside_singleton_context + @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY) + module Foo + class Bar + class << self + class Baz + class << self + def found_me!; end + end + end + end + end + end + RUBY + + entry = @index.resolve_method("found_me!", "Foo::Bar::::Baz::")&.first + refute_nil(entry) + + assert_equal("found_me!", T.must(entry).name) + end + + def test_resolving_constants_in_singleton_contexts + @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY) + module Foo + class Bar + CONST = 3 + + class << self + CONST = 2 + + class Baz + CONST = 1 + + class << self + end + end + end + end + end + RUBY + + entry = @index.resolve("CONST", ["Foo", "Bar", "", "Baz", ""])&.first + refute_nil(entry) + assert_equal(9, T.must(entry).location.start_line) + end + + def test_resolving_instance_variables_in_singleton_contexts + @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY) + module Foo + class Bar + @a = 123 + + class << self + def hello + @b = 123 + end + + @c = 123 + end + end + end + RUBY + + entry = @index.resolve_instance_variable("@a", "Foo::Bar::")&.first + refute_nil(entry) + assert_equal("@a", T.must(entry).name) + + entry = @index.resolve_instance_variable("@b", "Foo::Bar::")&.first + refute_nil(entry) + assert_equal("@b", T.must(entry).name) + + entry = @index.resolve_instance_variable("@c", "Foo::Bar::::>")&.first + refute_nil(entry) + assert_equal("@c", T.must(entry).name) + end + + def test_instance_variable_completion_in_singleton_contexts + @index.index_single(IndexablePath.new(nil, "/fake/path/foo.rb"), <<~RUBY) + module Foo + class Bar + @a = 123 + + class << self + def hello + @b = 123 + end + + @c = 123 + end + end + end + RUBY + + entries = @index.instance_variable_completion_candidates("@", "Foo::Bar::").map(&:name) + assert_includes(entries, "@a") + assert_includes(entries, "@b") + + assert_includes( + @index.instance_variable_completion_candidates("@", "Foo::Bar::::>").map(&:name), + "@c", + ) + end end end diff --git a/lib/ruby_indexer/test/instance_variables_test.rb b/lib/ruby_indexer/test/instance_variables_test.rb index e96005e45..291c76b0b 100644 --- a/lib/ruby_indexer/test/instance_variables_test.rb +++ b/lib/ruby_indexer/test/instance_variables_test.rb @@ -20,7 +20,9 @@ def initialize assert_entry("@a", Entry::InstanceVariable, "/fake/path/foo.rb:4-6:4-8") entry = T.must(@index["@a"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::Class, owner) + assert_equal("Foo::Bar", owner.name) end def test_instance_variable_and_write @@ -38,7 +40,9 @@ def initialize assert_entry("@a", Entry::InstanceVariable, "/fake/path/foo.rb:4-6:4-8") entry = T.must(@index["@a"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::Class, owner) + assert_equal("Foo::Bar", owner.name) end def test_instance_variable_operator_write @@ -56,7 +60,9 @@ def initialize assert_entry("@a", Entry::InstanceVariable, "/fake/path/foo.rb:4-6:4-8") entry = T.must(@index["@a"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::Class, owner) + assert_equal("Foo::Bar", owner.name) end def test_instance_variable_or_write @@ -74,7 +80,9 @@ def initialize assert_entry("@a", Entry::InstanceVariable, "/fake/path/foo.rb:4-6:4-8") entry = T.must(@index["@a"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::Class, owner) + assert_equal("Foo::Bar", owner.name) end def test_instance_variable_target @@ -93,10 +101,14 @@ def initialize assert_entry("@b", Entry::InstanceVariable, "/fake/path/foo.rb:4-10:4-12") entry = T.must(@index["@a"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::Class, owner) + assert_equal("Foo::Bar", owner.name) entry = T.must(@index["@b"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::Class, owner) + assert_equal("Foo::Bar", owner.name) end def test_empty_name_instance_variables @@ -118,6 +130,14 @@ def test_class_instance_variables module Foo class Bar @a = 123 + + class << self + def hello + @b = 123 + end + + @c = 123 + end end end RUBY @@ -125,7 +145,32 @@ class Bar assert_entry("@a", Entry::InstanceVariable, "/fake/path/foo.rb:2-4:2-6") entry = T.must(@index["@a"]&.first) - assert_equal("Foo::Bar", T.must(entry.owner).name) + owner = T.must(entry.owner) + assert_instance_of(Entry::SingletonClass, owner) + assert_equal("Foo::Bar::", owner.name) + + assert_entry("@b", Entry::InstanceVariable, "/fake/path/foo.rb:6-8:6-10") + + entry = T.must(@index["@b"]&.first) + owner = T.must(entry.owner) + assert_instance_of(Entry::SingletonClass, owner) + assert_equal("Foo::Bar::", owner.name) + + assert_entry("@c", Entry::InstanceVariable, "/fake/path/foo.rb:9-6:9-8") + + entry = T.must(@index["@c"]&.first) + owner = T.must(entry.owner) + assert_instance_of(Entry::SingletonClass, owner) + assert_equal("Foo::Bar::::>", owner.name) + end + + def test_top_level_instance_variables + index(<<~RUBY) + @a = 123 + RUBY + + entry = T.must(@index["@a"]&.first) + assert_nil(entry.owner) end end end diff --git a/lib/ruby_indexer/test/method_test.rb b/lib/ruby_indexer/test/method_test.rb index d71bc5481..88287b296 100644 --- a/lib/ruby_indexer/test/method_test.rb +++ b/lib/ruby_indexer/test/method_test.rb @@ -13,7 +13,7 @@ def bar end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") end def test_conditional_method @@ -24,7 +24,7 @@ def bar end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") end def test_singleton_method_using_self_receiver @@ -35,7 +35,12 @@ def self.bar end RUBY - assert_entry("bar", Entry::SingletonMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") + + entry = T.must(@index["bar"].first) + owner = T.must(entry.owner) + assert_equal("Foo::", owner.name) + assert_instance_of(Entry::SingletonClass, owner) end def test_singleton_method_using_other_receiver_is_not_indexed @@ -83,9 +88,9 @@ def bar; end def baz; end RUBY - assert_entry("foo", Entry::InstanceMethod, "/fake/path/foo.rb:0-8:1-3", visibility: Entry::Visibility::PRIVATE) - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:3-0:3-12", visibility: Entry::Visibility::PUBLIC) - assert_entry("baz", Entry::InstanceMethod, "/fake/path/foo.rb:7-0:7-12", visibility: Entry::Visibility::PROTECTED) + assert_entry("foo", Entry::Method, "/fake/path/foo.rb:0-8:1-3", visibility: Entry::Visibility::PRIVATE) + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:3-0:3-12", visibility: Entry::Visibility::PUBLIC) + assert_entry("baz", Entry::Method, "/fake/path/foo.rb:7-0:7-12", visibility: Entry::Visibility::PROTECTED) end def test_visibility_tracking_with_nested_class_or_modules @@ -103,9 +108,9 @@ def baz; end end RUBY - assert_entry("foo", Entry::InstanceMethod, "/fake/path/foo.rb:3-2:3-14", visibility: Entry::Visibility::PRIVATE) - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:6-4:6-16", visibility: Entry::Visibility::PUBLIC) - assert_entry("baz", Entry::InstanceMethod, "/fake/path/foo.rb:9-2:9-14", visibility: Entry::Visibility::PRIVATE) + assert_entry("foo", Entry::Method, "/fake/path/foo.rb:3-2:3-14", visibility: Entry::Visibility::PRIVATE) + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:6-4:6-16", visibility: Entry::Visibility::PUBLIC) + assert_entry("baz", Entry::Method, "/fake/path/foo.rb:9-2:9-14", visibility: Entry::Visibility::PRIVATE) end def test_method_with_parameters @@ -116,7 +121,7 @@ def bar(a) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(1, entry.parameters.length) parameter = entry.parameters.first @@ -132,7 +137,7 @@ def bar((a, (b, ))) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(1, entry.parameters.length) parameter = entry.parameters.first @@ -148,7 +153,7 @@ def bar(a = 123) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(1, entry.parameters.length) parameter = entry.parameters.first @@ -164,7 +169,7 @@ def bar(a:, b: 123) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(2, entry.parameters.length) a, b = entry.parameters @@ -184,7 +189,7 @@ def bar(*a, **b) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(2, entry.parameters.length) a, b = entry.parameters @@ -209,7 +214,7 @@ def qux(*a, (b, c)) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(2, entry.parameters.length) a, b = entry.parameters @@ -246,7 +251,7 @@ def bar((a, *b)) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(1, entry.parameters.length) param = entry.parameters.first @@ -287,7 +292,7 @@ def bar(*, **) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_equal(2, entry.parameters.length) first, second = entry.parameters @@ -307,7 +312,7 @@ def bar(**nil) end RUBY - assert_entry("bar", Entry::InstanceMethod, "/fake/path/foo.rb:1-2:2-5") + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:2-5") entry = T.must(@index["bar"].first) assert_empty(entry.parameters) end @@ -398,5 +403,30 @@ class Foo # Foo plus 3 valid aliases assert_equal(4, @index.instance_variable_get(:@entries).length - @default_indexed_entries.length) end + + def test_singleton_methods + index(<<~RUBY) + class Foo + def self.bar; end + + class << self + def baz; end + end + end + RUBY + + assert_entry("bar", Entry::Method, "/fake/path/foo.rb:1-2:1-19") + assert_entry("baz", Entry::Method, "/fake/path/foo.rb:4-4:4-16") + + bar_owner = T.must(T.must(@index["bar"].first).owner) + baz_owner = T.must(T.must(@index["baz"].first).owner) + + assert_instance_of(Entry::SingletonClass, bar_owner) + assert_instance_of(Entry::SingletonClass, baz_owner) + + # Regardless of whether the method was added through `self.something` or `class << self`, the owner object must be + # the exact same + assert_same(bar_owner, baz_owner) + end end end