From 4da6df0116e39ed260754acca0955b4461e1014e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Su=C4=8Di=C4=87?= Date: Mon, 20 Jul 2026 13:05:32 +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 | 29 ++++++++ test/requests/definition_expectations_test.rb | 68 +++++++++++++++++++ vscode/package.json | 14 ++++ 5 files changed, 127 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..bd2c8bc36c 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 5845300aac..364daf7d14 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 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 + } + } } } },