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..6e36f4470 100644 --- a/lib/ruby_lsp/listeners/definition.rb +++ b/lib/ruby_lsp/listeners/definition.rb @@ -405,6 +405,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 && @global_state.feature_configuration(:definition)&.enabled?(:preferPrimaryDefinition) + 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 +427,32 @@ 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| + next false unless entry.is_a?(RubyIndexer::Entry::Namespace) + + full_path = entry.uri.full_path + next false unless full_path + + 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..26944ecbd 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -136,6 +136,84 @@ class Baz end end + def test_prefer_primary_definition_when_constant_is_reopened + source = <<~RUBY + Core::Flags + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.global_state.apply_options({ + initializationOptions: { + featuresConfiguration: { definition: { preferPrimaryDefinition: true } }, + }, + }) + server.global_state.instance_variable_set(:@has_type_checker, false) + + index = server.global_state.index + primary_uri = URI::Generic.from_path(path: "/workspace/packs/core/flags.rb") + index.index_single(primary_uri, <<~RUBY) + module Core + class Flags + end + end + RUBY + index.index_single(URI::Generic.from_path(path: "/workspace/packs/billing/lib/patches.rb"), <<~RUBY) + module Core + class Flags + end + end + RUBY + + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 0, character: 6 } }, + ) + + 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 + Core::Flags + RUBY + + with_server(source, stub_no_typechecker: true) do |server, uri| + server.global_state.apply_options({ + initializationOptions: { + featuresConfiguration: { definition: { preferPrimaryDefinition: true } }, + }, + }) + server.global_state.instance_variable_set(:@has_type_checker, false) + + index = server.global_state.index + index.index_single(URI::Generic.from_path(path: "/workspace/lib/one.rb"), <<~RUBY) + module Core + class Flags + end + end + RUBY + index.index_single(URI::Generic.from_path(path: "/workspace/lib/two.rb"), <<~RUBY) + module Core + class Flags + end + end + RUBY + + server.process_message( + id: 1, + method: "textDocument/definition", + params: { textDocument: { uri: uri }, position: { line: 0, character: 6 } }, + ) + + 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 + } + } } } },