diff --git a/lib/ruby_lsp/requests/definition.rb b/lib/ruby_lsp/requests/definition.rb index 53ad3c29b..8b1324013 100644 --- a/lib/ruby_lsp/requests/definition.rb +++ b/lib/ruby_lsp/requests/definition.rb @@ -19,40 +19,55 @@ def initialize(document, global_state, position, dispatcher, sorbet_level) char_position, _ = document.find_index_by_position(position) delegate_request_if_needed!(global_state, document, char_position) + node_types = [ + Prism::CallNode, + Prism::ConstantReadNode, + Prism::ConstantPathNode, + Prism::BlockArgumentNode, + Prism::GlobalVariableAndWriteNode, + Prism::GlobalVariableOperatorWriteNode, + Prism::GlobalVariableOrWriteNode, + Prism::GlobalVariableReadNode, + Prism::GlobalVariableTargetNode, + Prism::GlobalVariableWriteNode, + Prism::InstanceVariableReadNode, + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableTargetNode, + Prism::InstanceVariableWriteNode, + Prism::SymbolNode, + Prism::StringNode, + Prism::SuperNode, + Prism::ForwardingSuperNode, + Prism::ClassVariableAndWriteNode, + Prism::ClassVariableOperatorWriteNode, + Prism::ClassVariableOrWriteNode, + Prism::ClassVariableReadNode, + Prism::ClassVariableTargetNode, + Prism::ClassVariableWriteNode, + ] + node_context = RubyDocument.locate( document.ast, char_position, - node_types: [ - Prism::CallNode, - Prism::ConstantReadNode, - Prism::ConstantPathNode, - Prism::BlockArgumentNode, - Prism::GlobalVariableAndWriteNode, - Prism::GlobalVariableOperatorWriteNode, - Prism::GlobalVariableOrWriteNode, - Prism::GlobalVariableReadNode, - Prism::GlobalVariableTargetNode, - Prism::GlobalVariableWriteNode, - Prism::InstanceVariableReadNode, - Prism::InstanceVariableAndWriteNode, - Prism::InstanceVariableOperatorWriteNode, - Prism::InstanceVariableOrWriteNode, - Prism::InstanceVariableTargetNode, - Prism::InstanceVariableWriteNode, - Prism::SymbolNode, - Prism::StringNode, - Prism::SuperNode, - Prism::ForwardingSuperNode, - Prism::ClassVariableAndWriteNode, - Prism::ClassVariableOperatorWriteNode, - Prism::ClassVariableOrWriteNode, - Prism::ClassVariableReadNode, - Prism::ClassVariableTargetNode, - Prism::ClassVariableWriteNode, - ], + node_types: node_types, code_units_cache: document.code_units_cache, ) + if char_position.positive? && + (node_context.node.is_a?(Prism::ProgramNode) || position_outside_target?(position, node_context.node)) + previous_node_context = RubyDocument.locate( + document.ast, + char_position - 1, + node_types: node_types, + code_units_cache: document.code_units_cache, + ) + if reference_ends_at_position?(previous_node_context.node, char_position, document) + node_context = previous_node_context + end + end + target = node_context.node parent = node_context.parent @@ -99,6 +114,22 @@ def perform private + #: (Prism::Node?, Integer, RubyDocument | ERBDocument) -> bool + def reference_ends_at_position?(node, char_position, document) + return false unless node && !node.is_a?(Prism::ProgramNode) + + location = case node + when Prism::CallNode + node.message_loc + when Prism::ConstantPathNode + node.name_loc + else + node.location + end + + location&.cached_end_code_units_offset(document.code_units_cache) == char_position + end + #: (Hash[Symbol, untyped] position, Prism::Node? target) -> bool def position_outside_target?(position, target) case target diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index 5845300aa..897e0f143 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -136,6 +136,124 @@ class Baz end end + def test_go_to_definition_when_cursor_is_after_reference + source = <<~RUBY + class Foo; end + + Foo + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 2, character: 3 } }, + ) + + response = server.pop_response.response + + refute_empty(response) + assert_equal(uri.to_s, response.first.attributes[:targetUri]) + target_range = response.first.attributes[:targetRange].attributes + assert_equal(0, target_range[:start].to_hash[:line]) + end + end + + def test_go_to_definition_when_cursor_is_after_method_reference + source = <<~RUBY + class Bar + def self.something; end + end + + Bar.something + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 4, character: 13 } }, + ) + + response = server.pop_response.response + + refute_empty(response) + assert_equal(uri.to_s, response.first.attributes[:targetUri]) + target_range = response.first.attributes[:targetRange].attributes + assert_equal(1, target_range[:start].to_hash[:line]) + end + end + + def test_go_to_definition_at_reference_boundaries + constants = "module Foo\n class Bar; end\nend\n" + method = "def consume(value); end\n" + cases = { + "bare constant" => ["class Foo; end\nFoo|\n", 0], + "method reference" => ["class Bar\n def self.something; end\nend\nBar.something|\n", 1], + "qualified constant" => [constants + "Foo::Bar|\n", 1], + "absolute constant" => ["class Foo; end\n::Foo|\n", 0], + "absolute qualified constant" => [constants + "::Foo::Bar|\n", 1], + "deep qualified constant" => [ + "module Outer\n module Inner\n class Leaf; end\n end\nend\nOuter::Inner::Leaf|\n", 2, + ], + "lexically nested constant" => ["module Foo\n class Bar; end\n Bar|\nend\n", 1], + "qualified argument" => [method + constants + "consume(Foo::Bar|)\n", 2], + "bare argument" => ["class Foo; end\n" + method + "consume(Foo|)\n", 0], + "array element" => [constants + "[Foo::Bar|]\n", 1], + "before whitespace" => [constants + "Foo::Bar| \n", 1], + "after whitespace" => [constants + "Foo::Bar |\n", nil], + "unknown constant" => [constants + "Foo::Missing|\n", nil], + "partial constant" => [constants + "Foo::Ba|\n", nil], + "inside a constant" => [constants + "Foo::Ba|r\n", 1], + "start of terminal constant" => [constants + "Foo::|Bar\n", 1], + "preserves the current namespace separator target" => [constants + "Foo|::Bar\n", 1], + "incomplete constant path" => [constants + "Foo::|\n", nil], + "line start" => [constants + "|Foo::Bar\n", 0], + "multibyte constant" => ["module Fほげ\n class Bar; end\nend\nFほげ::Bar|\n", 1], + "surrogate pair before reference" => [constants + '"😀"; Foo::Bar|' + "\n", 1], + "argument after whitespace" => ["class Foo; end\n" + method + "consume(Foo |)\n", nil], + "unknown argument" => ["class Foo; end\n" + method + "consume(Missing|)\n", nil], + "call name" => ["class Host\n def self.consume(value); end\nend\nHost.consume|(1)\n", 1], + "after call parentheses" => [method + "consume(1)|\n", nil], + "after a constant argument call" => [method + constants + "consume(Foo::Bar)|\n", nil], + "numeric argument" => [method + "consume(1|)\n", nil], + "string argument" => [method + 'consume("Foo"|)' + "\n", nil], + "symbol argument" => [method + "consume(:Foo|)\n", nil], + "inside a comment" => [constants + "Foo::Bar # comment|\n", nil], + "after array delimiter" => [constants + "[Foo::Bar]|\n", nil], + "write target" => [constants + "Foo::Bar| = nil\n", 1], + "assignment value" => [constants + "value = Foo::Bar|\n", 1], + "next argument" => [method + constants + "consume(Foo::Bar, |1)\n", nil], + } + + cases.each do |name, (marked_source, expected_line)| + before_cursor, after_cursor = marked_source.split("|", 2) + source = before_cursor + after_cursor + position = { + line: before_cursor.count("\n"), + character: before_cursor.split("\n", -1).last.encode(Encoding::UTF_16LE).bytesize / 2, + } + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: position }, + ) + response = server.pop_response.response + + if expected_line + refute_empty(response, name) + assert_equal(uri.to_s, response.first.attributes[:targetUri], name) + target_range = response.first.attributes[:targetSelectionRange].attributes + assert_equal(expected_line, target_range[:start].to_hash[:line], name) + else + assert_empty(response, name) + end + end + end + end + def test_multibyte_character_precision source = <<~RUBY module Fほげ