diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 111ef62f3e..33bb3324c8 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -23,6 +23,9 @@ class GlobalState sig { returns(T::Boolean) } attr_reader :supports_watching_files + sig { returns(TypeChecker) } + attr_reader :type_checker + sig { void } def initialize @workspace_uri = T.let(URI::Generic.from_path(path: Dir.pwd), URI::Generic) @@ -33,6 +36,7 @@ def initialize @test_library = T.let("minitest", String) @has_type_checker = T.let(true, T::Boolean) @index = T.let(RubyIndexer::Index.new, RubyIndexer::Index) + @type_checker = T.let(TypeChecker.new(@index), TypeChecker) @supported_formatters = T.let({}, T::Hash[String, Requests::Support::Formatter]) @supports_watching_files = T.let(false, T::Boolean) end diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 8103cb1e8f..270c7289d5 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -28,6 +28,7 @@ require "ruby_lsp/parameter_scope" require "ruby_lsp/global_state" require "ruby_lsp/server" +require "ruby_lsp/type_checker" require "ruby_lsp/requests" require "ruby_lsp/response_builders" require "ruby_lsp/node_context" diff --git a/lib/ruby_lsp/type_checker.rb b/lib/ruby_lsp/type_checker.rb new file mode 100644 index 0000000000..06e9a49489 --- /dev/null +++ b/lib/ruby_lsp/type_checker.rb @@ -0,0 +1,79 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + # A minimalistic type checker to try to resolve types that can be inferred without requiring a type system or + # annotations + class TypeChecker + extend T::Sig + + sig { params(index: RubyIndexer::Index).void } + def initialize(index) + @index = index + end + + sig { params(node_context: NodeContext).returns(T.nilable(String)) } + def infer_receiver_type(node_context) + node = node_context.node + + case node + when Prism::CallNode + infer_receiver_for_call_node(node, node_context) + when Prism::InstanceVariableReadNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableWriteNode, + Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableTargetNode + + return node_context.fully_qualified_name if node_context.surrounding_method + + nesting = node_context.nesting + "#{nesting.join("::")}::" + end + end + + private + + sig { params(node: Prism::CallNode, node_context: NodeContext).returns(T.nilable(String)) } + def infer_receiver_for_call_node(node, node_context) + receiver = node.receiver + + case receiver + when Prism::SelfNode, nil + return node_context.fully_qualified_name if node_context.surrounding_method + + # If we're not inside a method, then we're inside the body of a class or module, which is a singleton + # context + nesting = node_context.nesting + "#{nesting.join("::")}::" + when Prism::ConstantPathNode, Prism::ConstantReadNode + # When the receiver is a constant reference, we have to try to resolve it to figure out the right + # receiver. But since the invocation is directly on the constant, that's the singleton context of that + # class/module + receiver_name = constant_name(receiver) + return unless receiver_name + + resolved_receiver = @index.resolve(receiver_name, node_context.nesting) + name = resolved_receiver&.first&.name + return unless name + + *parts, last = name.split("::") + return "#{last}::" if T.must(parts).empty? + + "#{T.must(parts).join("::")}::#{last}::" + end + end + + sig do + params( + node: T.any( + Prism::ConstantPathNode, + Prism::ConstantReadNode, + ), + ).returns(T.nilable(String)) + end + def constant_name(node) + node.full_name + rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError, + Prism::ConstantPathNode::MissingNodesInConstantPathError + nil + end + end +end diff --git a/test/type_checker_test.rb b/test/type_checker_test.rb new file mode 100644 index 0000000000..04bf3e7fe6 --- /dev/null +++ b/test/type_checker_test.rb @@ -0,0 +1,171 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module RubyLsp + class TypeCheckerTest < Minitest::Test + def setup + @index = RubyIndexer::Index.new + @type_checker = TypeChecker.new(@index) + end + + def test_infer_receiver_type_self_inside_method + node_context = index_and_locate({ line: 2, character: 4 }, <<~RUBY) + class Foo + def bar + baz + end + end + RUBY + + assert_equal("Foo", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_self_inside_class_body + node_context = index_and_locate({ line: 1, character: 2 }, <<~RUBY) + class Foo + baz + end + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_self_inside_singleton_method + node_context = index_and_locate({ line: 2, character: 4 }, <<~RUBY) + class Foo + def self.bar + baz + end + end + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_self_inside_singleton_block_body + node_context = index_and_locate({ line: 2, character: 4 }, <<~RUBY) + class Foo + class << self + baz + end + end + RUBY + + assert_equal("Foo::::>", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_self_inside_singleton_block_method + node_context = index_and_locate({ line: 3, character: 6 }, <<~RUBY) + class Foo + class << self + def bar + baz + end + end + end + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_constant + node_context = index_and_locate({ line: 4, character: 4 }, <<~RUBY) + class Foo + def bar; end + end + + Foo.bar + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_constant_path + node_context = index_and_locate({ line: 6, character: 9 }, <<~RUBY) + module Foo + class Bar + def baz; end + end + end + + Foo::Bar.baz + RUBY + + assert_equal("Foo::Bar::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_instance_variables_in_class_body + node_context = index_and_locate({ line: 1, character: 2 }, <<~RUBY) + class Foo + @hello1 + end + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_instance_variables_in_singleton_method + node_context = index_and_locate({ line: 2, character: 4 }, <<~RUBY) + class Foo + def self.bar + @hello1 + end + end + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_instance_variables_in_singleton_block_body + node_context = index_and_locate({ line: 2, character: 4 }, <<~RUBY) + class Foo + class << self + @hello1 + end + end + RUBY + + assert_equal("Foo::::>", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_instance_variables_in_singleton_block_method + node_context = index_and_locate({ line: 3, character: 6 }, <<~RUBY) + class Foo + class << self + def bar + @hello1 + end + end + end + RUBY + + assert_equal("Foo::", @type_checker.infer_receiver_type(node_context)) + end + + def test_infer_receiver_type_instance_variables_in_instance_method + node_context = index_and_locate({ line: 2, character: 4 }, <<~RUBY) + class Foo + def bar + @hello1 + end + end + RUBY + + assert_equal("Foo", @type_checker.infer_receiver_type(node_context)) + end + + private + + def index_and_locate(position, source) + @index.index_single(RubyIndexer::IndexablePath.new(nil, "/fake/path/foo.rb"), source) + document = RubyLsp::RubyDocument.new( + source: source, + version: 1, + uri: URI::Generic.build(scheme: "file", path: "/fake/path/foo.rb"), + ) + document.locate_node(position) + end + end +end