From 616de01b64acd3cf0d12e4e5a4f800231b7236bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Su=C4=8Di=C4=87?= Date: Mon, 20 Jul 2026 12:49:41 +0200 Subject: [PATCH] Add preferPrimaryDefinition setting for go to definition --- jekyll/index.markdown | 12 +++ lib/ruby_lsp/global_state.rb | 4 + lib/ruby_lsp/listeners/definition.rb | 30 +++++++ test/requests/definition_expectations_test.rb | 78 +++++++++++++++++++ vscode/package.json | 14 ++++ 5 files changed, 138 insertions(+) diff --git a/jekyll/index.markdown b/jekyll/index.markdown index 5c8a57fc39..77b99dd39c 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 82e6edbcf5..f272f9c06e 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 38478712cf..6e36f44709 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 5845300aac..26944ecbd9 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 c040da5f35..62c5565a37 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 + } + } } } },