diff --git a/jekyll/index.markdown b/jekyll/index.markdown index 5c8a57fc3..77b99dd39 100644 --- a/jekyll/index.markdown +++ b/jekyll/index.markdown @@ -157,6 +157,18 @@ Users see a dropdown with all the sources, along with a preview window on the si Sorry, your browser doesn't support embedded videos. This video demonstrates the go-to-definition feature when multiple definitions are found, showing the dropdown and preview window. +If a class or module is reopened in many files, jumping to it lists every file where it is defined. If you prefer to +jump directly to the primary definition (the file whose path matches the fully qualified constant name following the +Zeitwerk convention), enable the following setting: + +```jsonc +{ + "rubyLsp.featuresConfiguration.definition.preferPrimaryDefinition": true +} +``` + +When no file matches the convention, all definitions are listed as usual. + ### Completion The completion feature provides users with completion candidates when the text they type matches certain indexed components. This helps speed up coding by reducing the need to type out full method names or constants. diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 82e6edbcf..f272f9c06 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -81,6 +81,10 @@ def initialize enableAll: false, enableTestCodeLens: true, }), + definition: RequestConfig.new({ + enableAll: false, + preferPrimaryDefinition: false, + }), } #: Hash[Symbol, RequestConfig] end diff --git a/lib/ruby_lsp/listeners/definition.rb b/lib/ruby_lsp/listeners/definition.rb index 38478712c..bd2c8bc36 100644 --- a/lib/ruby_lsp/listeners/definition.rb +++ b/lib/ruby_lsp/listeners/definition.rb @@ -18,6 +18,9 @@ def initialize(response_builder, global_state, language_id, uri, node_context, d @uri = uri @node_context = node_context @sorbet_level = sorbet_level + @prefer_primary_definition = global_state + .feature_configuration(:definition) + &.enabled?(:preferPrimaryDefinition) || false #: bool dispatcher.register( self, @@ -405,6 +408,10 @@ def find_in_index(value) first_entry = entries.first #: as !nil return if first_entry.private? && first_entry.name != "#{@node_context.fully_qualified_name}::#{value}" + if entries.length > 1 && @prefer_primary_definition + entries = select_primary_definitions(entries) + end + entries.each do |entry| # If the project has Sorbet, then we only want to handle go to definition for constants defined in gems, as an # additional behavior on top of jumping to RBIs. The only sigil where Sorbet cannot handle constants is typed @@ -423,6 +430,28 @@ def find_in_index(value) ) end end + + #: (Array[RubyIndexer::Entry] entries) -> Array[RubyIndexer::Entry] + def select_primary_definitions(entries) + primary_entries = entries.select do |entry| + entry.is_a?(RubyIndexer::Entry::Namespace) && + entry.uri.full_path&.end_with?(convention_based_path(entry.name)) + end + + primary_entries.empty? ? entries : primary_entries + end + + #: (String name) -> String + def convention_based_path(name) + parts = name.split("::").map do |part| + part + .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2") + .gsub(/([a-z\d])([A-Z])/, "\\1_\\2") + .downcase + end + + "/#{parts.join("/")}.rb" + end end end end diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index 5845300aa..364daf7d1 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -136,6 +136,74 @@ class Baz end end + def test_prefer_primary_definition_when_constant_is_reopened + source = <<~RUBY + RubyLsp::Server + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.global_state.feature_configuration(:definition)&.merge!(preferPrimaryDefinition: true) + + index = server.global_state.index + primary_uri = URI::Generic.from_path(path: "/workspace/lib/ruby_lsp/server.rb") + index.index_single(primary_uri, <<~RUBY) + module RubyLsp + class Server + end + end + RUBY + index.index_single(URI::Generic.from_path(path: "/workspace/lib/ruby_lsp/patches.rb"), <<~RUBY) + module RubyLsp + class Server + end + end + RUBY + + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 0, character: 9 } }, + ) + + response = server.pop_response.response + assert_equal(1, response.length) + assert_equal(primary_uri.to_s, response[0].attributes[:targetUri]) + end + end + + def test_prefer_primary_definition_falls_back_to_all_entries_without_a_convention_match + source = <<~RUBY + RubyLsp::Server + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.global_state.feature_configuration(:definition)&.merge!(preferPrimaryDefinition: true) + + index = server.global_state.index + index.index_single(URI::Generic.from_path(path: "/workspace/lib/one.rb"), <<~RUBY) + module RubyLsp + class Server + end + end + RUBY + index.index_single(URI::Generic.from_path(path: "/workspace/lib/two.rb"), <<~RUBY) + module RubyLsp + class Server + end + end + RUBY + + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 0, character: 9 } }, + ) + + response = server.pop_response.response + assert_equal(2, response.length) + end + end + def test_multibyte_character_precision source = <<~RUBY module Fほげ diff --git a/vscode/package.json b/vscode/package.json index c040da5f3..62c5565a3 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -368,6 +368,20 @@ "default": true } } + }, + "definition": { + "description": "Customize go to definition features", + "type": "object", + "properties": { + "enableAll": { + "type": "boolean" + }, + "preferPrimaryDefinition": { + "description": "When a class or module is reopened in multiple files, jump directly to its primary definition (the file whose path matches the fully qualified constant name) instead of listing every definition", + "type": "boolean", + "default": false + } + } } } },