diff --git a/lib/ruby_lsp/listeners/definition.rb b/lib/ruby_lsp/listeners/definition.rb index f0991ee21..be2c9f9eb 100644 --- a/lib/ruby_lsp/listeners/definition.rb +++ b/lib/ruby_lsp/listeners/definition.rb @@ -249,7 +249,7 @@ def handle_super_node_definition return unless surrounding_method handle_method_definition( - surrounding_method, + surrounding_method.name, @type_inferrer.infer_receiver_type(@node_context), inherited_only: true, ) diff --git a/lib/ruby_lsp/listeners/hover.rb b/lib/ruby_lsp/listeners/hover.rb index 8aadae7c4..3e43c2cc3 100644 --- a/lib/ruby_lsp/listeners/hover.rb +++ b/lib/ruby_lsp/listeners/hover.rb @@ -299,7 +299,7 @@ def handle_super_node_hover surrounding_method = @node_context.surrounding_method return unless surrounding_method - handle_method_hover(surrounding_method, inherited_only: true) + handle_method_hover(surrounding_method.name, inherited_only: true) end #: (String message, ?inherited_only: bool) -> void diff --git a/lib/ruby_lsp/node_context.rb b/lib/ruby_lsp/node_context.rb index 48d06f82c..f6a859d25 100644 --- a/lib/ruby_lsp/node_context.rb +++ b/lib/ruby_lsp/node_context.rb @@ -5,6 +5,21 @@ module RubyLsp # This class allows listeners to access contextual information about a node in the AST, such as its parent, # its namespace nesting, and the surrounding CallNode (e.g. a method call). class NodeContext + # Represents the surrounding method definition context, tracking both the method name and its receiver + class MethodDef + #: String + attr_reader :name + + #: String? + attr_reader :receiver + + #: (String name, String? receiver) -> void + def initialize(name, receiver) + @name = name + @receiver = receiver + end + end + #: Prism::Node? attr_reader :node, :parent @@ -14,7 +29,7 @@ class NodeContext #: Prism::CallNode? attr_reader :call_node - #: String? + #: MethodDef? attr_reader :surrounding_method #: (Prism::Node? node, Prism::Node? parent, Array[(Prism::ClassNode | Prism::ModuleNode | Prism::SingletonClassNode | Prism::DefNode | Prism::BlockNode | Prism::LambdaNode | Prism::ProgramNode)] nesting_nodes, Prism::CallNode? call_node) -> void @@ -26,7 +41,7 @@ def initialize(node, parent, nesting_nodes, call_node) nesting, surrounding_method = handle_nesting_nodes(nesting_nodes) @nesting = nesting #: Array[String] - @surrounding_method = surrounding_method #: String? + @surrounding_method = surrounding_method #: MethodDef? end #: -> String @@ -52,10 +67,10 @@ def locals_for_scope private - #: (Array[(Prism::ClassNode | Prism::ModuleNode | Prism::SingletonClassNode | Prism::DefNode | Prism::BlockNode | Prism::LambdaNode | Prism::ProgramNode)] nodes) -> [Array[String], String?] + #: (Array[(Prism::ClassNode | Prism::ModuleNode | Prism::SingletonClassNode | Prism::DefNode | Prism::BlockNode | Prism::LambdaNode | Prism::ProgramNode)] nodes) -> [Array[String], MethodDef?] def handle_nesting_nodes(nodes) nesting = [] - surrounding_method = nil #: String? + surrounding_method = nil #: MethodDef? @nesting_nodes.each do |node| case node @@ -64,10 +79,16 @@ def handle_nesting_nodes(nodes) when Prism::SingletonClassNode nesting << "<#{nesting.flat_map { |n| n.split("::") }.last}>" when Prism::DefNode - surrounding_method = node.name.to_s - next unless node.receiver.is_a?(Prism::SelfNode) - - nesting << "<#{nesting.flat_map { |n| n.split("::") }.last}>" + receiver = node.receiver + + surrounding_method = case receiver + when Prism::SelfNode + MethodDef.new(node.name.to_s, "self") + when Prism::ConstantReadNode, Prism::ConstantPathNode + MethodDef.new(node.name.to_s, receiver.slice) + else + MethodDef.new(node.name.to_s, nil) + end end end diff --git a/lib/ruby_lsp/type_inferrer.rb b/lib/ruby_lsp/type_inferrer.rb index f6c8b93b8..10a58f313 100644 --- a/lib/ruby_lsp/type_inferrer.rb +++ b/lib/ruby_lsp/type_inferrer.rb @@ -135,15 +135,90 @@ def self_receiver_handling(node_context) # If we're at the top level, then the invocation is happening on `
`, which is a special singleton that # inherits from Object return Type.new("Object") if nesting.empty? - return Type.new(node_context.fully_qualified_name) if node_context.surrounding_method + + surrounding_method = node_context.surrounding_method + + if surrounding_method + receiver_name = surrounding_method.receiver + + case receiver_name + when "self" + # `def self.foo` — self is the singleton of the enclosing class/module + return resolve_singleton_type_from_nesting(nesting) + when nil + # Instance method — self is an instance of the enclosing class/module + return resolve_type_from_nesting(nesting) + else + # Explicit constant receiver (e.g. `def Bar.baz`) — self is that constant's singleton class + resolved = resolve_receiver_singleton_type(receiver_name, nesting) + return resolved if resolved + + return resolve_type_from_nesting(nesting) + end + end # If we're not inside a method, then we're inside the body of a class or module, which is a singleton - # context. - # - # If the class/module definition is using compact style (e.g.: `class Foo::Bar`), then we need to split the name - # into its individual parts to build the correct singleton name - parts = nesting.flat_map { |part| part.split("::") } - Type.new("#{parts.join("::")}::<#{parts.last}>") + # context. Resolve through the graph to get the correct fully qualified name + resolve_singleton_type_from_nesting(nesting) + end + + # Resolves the fully qualified name of the innermost constant from the nesting and returns it as a type. + # For instance methods, the nesting won't have singleton markers, so the result is an instance type. + # For `def self.` methods, the nesting includes a singleton marker, which is preserved in the result. + #: (Array[String] nesting) -> Type + def resolve_type_from_nesting(nesting) + resolved_name = resolve_nesting_fully_qualified_name(nesting) + Type.new(resolved_name) + end + + # Resolves the nesting and returns a singleton type (appends `::`) + #: (Array[String] nesting) -> Type + def resolve_singleton_type_from_nesting(nesting) + resolved_name = resolve_nesting_fully_qualified_name(nesting) + last_part = resolved_name.split("::").last #: as !nil + Type.new("#{resolved_name}::<#{last_part}>") + end + + # Resolves the innermost constant in the nesting through the graph, handling compact-path definitions + # like `class Bar::Baz` inside a different module where the lexical nesting doesn't reflect the true + # constant hierarchy. Falls back to lexical joining if resolution fails. + #: (Array[String] nesting) -> String + def resolve_nesting_fully_qualified_name(nesting) + nesting_parts = nesting.dup + trailing_singletons = [] #: Array[String] + + nesting_parts.reverse_each do |part| + break unless part.start_with?("<") + + popped = nesting_parts.pop #: as !nil + trailing_singletons.unshift(popped) + end + + if nesting_parts.any? + resolved = @graph.resolve_constant( + nesting_parts.last, #: as !nil + nesting_parts[0...-1], #: as !nil + ) + + if resolved + parts = resolved.name.split("::") + trailing_singletons + return parts.join("::") + end + end + + # Fallback to lexical joining if resolution fails + nesting.flat_map { |part| part.split("::") }.join("::") + end + + #: (String, Array[String]) -> Type? + def resolve_receiver_singleton_type(receiver_name, nesting) + receiver_declaration = @graph.resolve_constant(receiver_name, nesting) + return unless receiver_declaration.is_a?(Rubydex::Namespace) + + singleton = receiver_declaration.singleton_class + return unless singleton + + Type.new(singleton.name) end #: (NodeContext node_context) -> Type? diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index f442c60b4..7e089ba1d 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -1419,6 +1419,294 @@ def bar end end + def test_definition_for_implicit_self_method_call_inside_singleton_method + source = <<~RUBY + # typed: false + + class Foo + def self.bar; end + + def self.baz + bar + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 6 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(3, response[0].target_range.start.line) + end + end + + def test_definition_for_method_call_inside_method_with_constant_receiver + source = <<~RUBY + # typed: false + + class Bar + def self.helper; end + end + + class Foo + def Bar.check + helper + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 8 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(3, response[0].target_range.start.line) + end + end + + def test_definition_for_super_inside_singleton_method + source = <<~RUBY + class Parent + def self.foo; end + end + + class Child < Parent + def self.foo + super + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 6 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(1, response[0].target_range.start.line) + end + end + + def test_definition_for_implicit_self_method_call_inside_singleton_method_with_compact_namespace + source = <<~RUBY + # typed: false + + module Foo; end + + class Foo::Bar + def self.helper; end + + def self.check + helper + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 8 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(5, response[0].target_range.start.line) + end + end + + def test_super_definition_for_method_definition_with_receiver + source = <<~RUBY + # typed: false + + class Foo + class << self + # You found me! + def bar; end + end + end + + class Bar < Foo + class << self + end + end + + class Qux + def Bar.bar + super + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 16 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(5, response[0].target_range.start.line) + end + end + + def test_definition_for_method_call_inside_class_singleton_block_method + source = <<~RUBY + # typed: false + + class Foo + def self.bar; end + + class << self + def baz + bar + end + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 6, line: 7 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(3, response[0].target_range.start.line) + end + end + + def test_definition_for_instance_variable_in_method_with_constant_receiver + source = <<~RUBY + class Bar + class << self; end + + @config = "default" + end + + class Foo + def Bar.configure + @config + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 8 } }, + ) + + # @config should resolve through Bar's singleton class, not Foo + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(3, response[0].range.start.line) + end + end + + def test_definition_for_class_variable_in_method_with_constant_receiver + source = <<~RUBY + class Bar + @@shared = 1 + end + + class Foo + def Bar.check + @@shared + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 6 } }, + ) + + # @@shared follows lexical scope (Foo), not the receiver (Bar). + # Since @@shared is defined in Bar but not in Foo, definition should be empty + assert_empty(server.pop_response.response) + end + end + + def test_definition_for_constant_in_method_with_constant_receiver + source = <<~RUBY + # typed: ignore + class Bar + OTHER = "other" + end + + class Foo + MY_CONST = "hello" + + def Bar.check + MY_CONST + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 4, line: 9 } }, + ) + + # MY_CONST resolves through Foo's lexical scope, not Bar + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(6, response[0].target_range.start.line) + end + end + + def test_definition_for_instance_variable_in_hoisted_parent_scope + source = <<~RUBY + module Bar; end + + module Foo + class Bar::Baz + class << self; end + + @var = 1 + + def self.get_var + @var + end + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { character: 6, line: 9 } }, + ) + + # @var should resolve through Bar::Baz's singleton class, not Foo::Bar::Baz + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(6, response[0].range.start.line) + end + end + private def create_definition_addon diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index 511bfa4d3..c59d27cd1 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -1057,6 +1057,283 @@ class Baz; end end end + def test_hover_for_instance_variable_in_method_with_constant_receiver + source = <<~RUBY + class Bar + class << self; end + + # Bar's class ivar + @config = "default" + end + + class Foo + def Bar.configure + @config + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 9 } }, + ) + + # @config should resolve through Bar's singleton class, not Foo + contents = server.pop_response.response.contents.value + assert_match("Bar's class ivar", contents) + end + end + + def test_hover_for_class_variable_in_method_with_constant_receiver + source = <<~RUBY + class Bar + # Bar's class var + @@shared = 1 + end + + class Foo + def Bar.check + @@shared + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 7 } }, + ) + + # @@shared follows lexical scope (Foo), not the receiver (Bar). + # Since @@shared is defined in Bar but not in Foo, hovering should return nil + assert_nil(server.pop_response.response) + end + end + + def test_hover_for_constant_in_method_with_constant_receiver + source = <<~RUBY + # typed: ignore + class Bar + OTHER = "other" + end + + class Foo + MY_CONST = "hello" + + def Bar.check + MY_CONST + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 9 } }, + ) + + # MY_CONST resolves through Foo's lexical scope, not Bar + contents = server.pop_response.response.contents.value + assert_match("Foo::MY_CONST", contents) + end + end + + def test_hover_for_implicit_self_method_call_inside_singleton_method + source = <<~RUBY + # typed: false + + class Foo + # Docs for bar + def self.bar; end + + def self.baz + bar + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 7 } }, + ) + + assert_match("Docs for bar", server.pop_response.response.contents.value) + end + end + + def test_hover_for_method_call_inside_method_with_constant_receiver + source = <<~RUBY + # typed: false + + class Bar + # Helper docs + def self.helper; end + end + + class Foo + def Bar.check + helper + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 9 } }, + ) + + assert_match("Helper docs", server.pop_response.response.contents.value) + end + end + + def test_hover_for_super_inside_singleton_method + source = <<~RUBY + class Parent + # Parent foo + def self.foo; end + end + + class Child < Parent + def self.foo + super + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 7 } }, + ) + + assert_match("Parent foo", server.pop_response.response.contents.value) + end + end + + def test_hover_for_implicit_self_method_call_inside_singleton_method_with_compact_namespace + source = <<~RUBY + # typed: false + + module Foo; end + + class Foo::Bar + # Helper docs + def self.helper; end + + def self.check + helper + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 9 } }, + ) + + assert_match("Helper docs", server.pop_response.response.contents.value) + end + end + + def test_super_hover_for_method_definition_with_receiver + source = <<~RUBY + # typed: false + + class Foo + class << self + # You found me! + def bar; end + end + end + + class Bar < Foo + class << self + end + end + + class Qux + def Bar.bar + super + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 16 } }, + ) + + assert_match("You found me!", server.pop_response.response.contents.value) + end + end + + def test_hover_for_method_call_inside_class_singleton_block_method + source = <<~RUBY + # typed: false + + class Foo + # Docs for bar + def self.bar; end + + class << self + def baz + bar + end + end + end + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 6, line: 8 } }, + ) + + assert_match("Docs for bar", server.pop_response.response.contents.value) + end + end + + def test_hover_for_instance_variable_in_hoisted_parent_scope + source = <<~RUBY + module Bar; end + + module Foo + class Bar::Baz + class << self; end + + # Baz's ivar + @var = 1 + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/hover", + params: { textDocument: { uri: uri }, position: { character: 4, line: 7 } }, + ) + + # @var should resolve through Bar::Baz's singleton class, not Foo::Bar::Baz + contents = server.pop_response.response.contents.value + assert_match("Baz's ivar", contents) + end + end + private def create_hover_addon diff --git a/test/ruby_document_test.rb b/test/ruby_document_test.rb index fe96e6fcf..301c6e3af 100644 --- a/test/ruby_document_test.rb +++ b/test/ruby_document_test.rb @@ -730,6 +730,44 @@ class Post < ActiveRecord::Base ) end + def test_locate_handles_method_receivers + document = RubyLsp::RubyDocument.new(source: <<~RUBY, version: 1, uri: @uri, global_state: @global_state) + class Bar; end + + class Foo + def Bar.baz + @var + end + end + RUBY + + node_context = document.locate_node({ line: 4, character: 4 }) + assert_instance_of(Prism::InstanceVariableReadNode, node_context.node) + assert_equal(["Foo"], node_context.nesting) + + surrounding_method = node_context.surrounding_method #: as !nil + assert_equal("baz", surrounding_method.name) + assert_equal("Bar", surrounding_method.receiver) + end + + def test_locate_constant_inside_method_with_receiver_uses_lexical_nesting + document = RubyLsp::RubyDocument.new(source: <<~RUBY, version: 1, uri: @uri, global_state: @global_state) + class Bar; end + + class Foo + def Bar.baz + MY_CONST + end + end + RUBY + + # Constants follow lexical scope, not the method receiver. The nesting must remain ["Foo"] + # so that MY_CONST resolves through Foo, not Bar + node_context = document.locate_node({ line: 4, character: 4 }) + assert_instance_of(Prism::ConstantReadNode, node_context.node) + assert_equal(["Foo"], node_context.nesting) + end + def test_locate_returns_nesting document = RubyLsp::RubyDocument.new(source: <<~RUBY, version: 1, uri: @uri, global_state: @global_state) module Foo @@ -953,8 +991,9 @@ def qux assert_nil(node_context.surrounding_method) node_context = document.locate_node({ line: 4, character: 4 }) - assert_equal(["Foo", ""], node_context.nesting) - assert_equal("bar", node_context.surrounding_method) + assert_equal(["Foo"], node_context.nesting) + assert_equal("bar", node_context.surrounding_method&.name) + assert_equal("self", node_context.surrounding_method&.receiver) node_context = document.locate_node({ line: 8, character: 4 }) assert_equal(["Foo", ""], node_context.nesting) @@ -962,11 +1001,13 @@ def qux node_context = document.locate_node({ line: 11, character: 6 }) assert_equal(["Foo", ""], node_context.nesting) - assert_equal("baz", node_context.surrounding_method) + assert_equal("baz", node_context.surrounding_method&.name) + assert_nil(node_context.surrounding_method&.receiver) node_context = document.locate_node({ line: 16, character: 6 }) assert_equal(["Foo"], node_context.nesting) - assert_equal("qux", node_context.surrounding_method) + assert_equal("qux", node_context.surrounding_method&.name) + assert_nil(node_context.surrounding_method&.receiver) end def test_locate_first_within_range diff --git a/test/type_inferrer_test.rb b/test/type_inferrer_test.rb index ee8b6c1d3..b964aefb5 100644 --- a/test/type_inferrer_test.rb +++ b/test/type_inferrer_test.rb @@ -44,6 +44,81 @@ def self.bar assert_equal("Foo::", @type_inferrer.infer_receiver_type(node_context).name) end + def test_infer_receiver_type_self_inside_method_with_constant_receiver + node_context = index_and_locate(<<~RUBY, { line: 4, character: 4 }) + class Bar; end + + class Foo + def Bar.baz + @var + end + end + RUBY + + assert_equal("Bar::", @type_inferrer.infer_receiver_type(node_context).name) + end + + def test_infer_receiver_type_instance_variables_in_method_with_constant_receiver + node_context = index_and_locate(<<~RUBY, { line: 4, character: 4 }) + class Bar; end + + class Foo + def Bar.baz + @hello1 + end + end + RUBY + + assert_equal("Bar::", @type_inferrer.infer_receiver_type(node_context).name) + end + + def test_infer_receiver_inside_hoisted_parent_scope + node_context = index_and_locate(<<~RUBY, { line: 4, character: 4 }) + module Bar; end + + module Foo + class Bar::Baz + @var + end + end + RUBY + + assert_equal("Bar::Baz::", @type_inferrer.infer_receiver_type(node_context).name) + end + + def test_infer_receiver_inside_inherited_parent_scope + node_context = index_and_locate(<<~RUBY, { line: 8, character: 4 }) + module Bar + module Baz; end + end + + module Foo + include Bar + + class Baz::Qux + @var + end + end + RUBY + + assert_equal("Bar::Baz::Qux::", @type_inferrer.infer_receiver_type(node_context).name) + end + + def test_infer_receiver_type_class_variables_in_method_with_constant_receiver + node_context = index_and_locate(<<~RUBY, { line: 4, character: 4 }) + class Bar; end + + class Foo + def Bar.baz + @@hello1 + end + end + RUBY + + # Class variables follow lexical scope, not the method receiver + assert_equal("Foo", @type_inferrer.infer_receiver_type(node_context).name) + end + def test_infer_receiver_type_self_inside_singleton_block_body node_context = index_and_locate(<<~RUBY, { line: 2, character: 4 }) class Foo