diff --git a/lib/ruby_lsp/ruby_lsp_rails/definition.rb b/lib/ruby_lsp/ruby_lsp_rails/definition.rb index 20a0869f..34ec81ba 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/definition.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/definition.rb @@ -109,7 +109,7 @@ def handle_association(node) association_name = first_argument.unescaped - result = @client.association_target_location( + result = @client.association_target( model_name: @nesting.join("::"), association_name: association_name, ) diff --git a/lib/ruby_lsp/ruby_lsp_rails/hover.rb b/lib/ruby_lsp/ruby_lsp_rails/hover.rb index 6398848e..26b4d2b5 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/hover.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/hover.rb @@ -21,9 +21,15 @@ class Hover def initialize(client, response_builder, node_context, global_state, dispatcher) @client = client @response_builder = response_builder + @node_context = node_context @nesting = node_context.nesting #: Array[String] @index = global_state.index #: RubyIndexer::Index - dispatcher.register(self, :on_constant_path_node_enter, :on_constant_read_node_enter) + dispatcher.register( + self, + :on_constant_path_node_enter, + :on_constant_read_node_enter, + :on_symbol_node_enter, + ) end #: (Prism::ConstantPathNode node) -> void @@ -43,6 +49,11 @@ def on_constant_read_node_enter(node) generate_column_content(entries.first.name) end + #: (Prism::SymbolNode node) -> void + def on_symbol_node_enter(node) + handle_possible_dsl(node) + end + private #: (String name) -> void @@ -102,6 +113,57 @@ def format_default(default_value, type) default_value end end + + #: (Prism::SymbolNode node) -> void + def handle_possible_dsl(node) + node = @node_context.call_node + return unless node + return unless self_receiver?(node) + + message = node.message + + return unless message + + if Support::Associations::ALL.include?(message) + handle_association(node) + end + end + + #: (Prism::CallNode node) -> void + def handle_association(node) + first_argument = node.arguments&.arguments&.first + return unless first_argument.is_a?(Prism::SymbolNode) + + association_name = first_argument.unescaped + + result = @client.association_target( + model_name: @nesting.join("::"), + association_name: association_name, + ) + + return unless result + + name = result[:name] + + generate_hover(name) + end + + # Copied from `RubyLsp::Rails::Hover#generate_hover` + #: (String name) -> void + def generate_hover(name) + entries = @index.resolve(name, @node_context.nesting) + return unless entries + + # We should only show hover for private constants if the constant is defined in the same namespace as the + # reference + first_entry = entries.first + full_name = first_entry.name + return if first_entry.private? && full_name != "#{@node_context.fully_qualified_name}::#{name}" + + categorized_markdown_from_index_entries(full_name, entries).each do |category, content| + @response_builder.push(content, category: category) + end + end end end end diff --git a/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb b/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb index a2cb0afe..315224a3 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/runner_client.rb @@ -144,9 +144,9 @@ def model(name) end #: (model_name: String, association_name: String) -> Hash[Symbol, untyped]? - def association_target_location(model_name:, association_name:) + def association_target(model_name:, association_name:) make_request( - "association_target_location", + "association_target", model_name: model_name, association_name: association_name, ) diff --git a/lib/ruby_lsp/ruby_lsp_rails/server.rb b/lib/ruby_lsp/ruby_lsp_rails/server.rb index 1c8404fb..d9349125 100644 --- a/lib/ruby_lsp/ruby_lsp_rails/server.rb +++ b/lib/ruby_lsp/ruby_lsp_rails/server.rb @@ -302,7 +302,7 @@ def execute(request, params) with_request_error_handling(request) do send_result(resolve_database_info_from_model(params.fetch(:name))) end - when "association_target_location" + when "association_target" with_request_error_handling(request) do send_result(resolve_association_target(params)) end @@ -427,7 +427,7 @@ def resolve_association_target(params) source_location = Object.const_source_location(association_klass.to_s) return unless source_location - { location: "#{source_location[0]}:#{source_location[1]}" } + { location: "#{source_location[0]}:#{source_location[1]}", name: association_klass.name } rescue NameError nil end diff --git a/test/ruby_lsp_rails/hover_test.rb b/test/ruby_lsp_rails/hover_test.rb index f1b48c70..e9bca9e4 100644 --- a/test/ruby_lsp_rails/hover_test.rb +++ b/test/ruby_lsp_rails/hover_test.rb @@ -218,6 +218,109 @@ class User < ApplicationRecord refute_match(/Schema/, response.contents.value) end + test "returns has_many association information" do + expected_response = { + location: "#{dummy_root}/app/models/membership.rb:5", + name: "Bar", + } + RunnerClient.any_instance.stubs(association_target: expected_response) + + response = hover_on_source(<<~RUBY, { line: 1, character: 11 }) + class Foo < ApplicationRecord + has_many :bars + end + + class Bar < ApplicationRecord + belongs_to :foo + end + RUBY + + assert_equal(<<~CONTENT.chomp, response.contents.value) + ```ruby + Bar + ``` + + **Definitions**: [fake.rb](file:///fake.rb#L5,1-7,4) + CONTENT + end + + test "returns belongs_to association information" do + expected_response = { + location: "#{dummy_root}/app/models/membership.rb:1", + name: "Foo", + } + RunnerClient.any_instance.stubs(association_target: expected_response) + + response = hover_on_source(<<~RUBY, { line: 5, character: 14 }) + class Foo < ApplicationRecord + has_many :bars + end + + class Bar < ApplicationRecord + belongs_to :foo + end + RUBY + + assert_equal(<<~CONTENT.chomp, response.contents.value) + ```ruby + Foo + ``` + + **Definitions**: [fake.rb](file:///fake.rb#L1,1-3,4) + CONTENT + end + + test "returns has_one association information" do + expected_response = { + location: "#{dummy_root}/app/models/membership.rb:5", + name: "Bar", + } + RunnerClient.any_instance.stubs(association_target: expected_response) + + response = hover_on_source(<<~RUBY, { line: 1, character: 10 }) + class Foo < ApplicationRecord + has_one :bar + end + + class Bar < ApplicationRecord + end + RUBY + + assert_equal(<<~CONTENT.chomp, response.contents.value) + ```ruby + Bar + ``` + + **Definitions**: [fake.rb](file:///fake.rb#L5,1-6,4) + CONTENT + end + + test "returns has_and_belongs_to association information" do + expected_response = { + location: "#{dummy_root}/app/models/membership.rb:5", + name: "Bar", + } + RunnerClient.any_instance.stubs(association_target: expected_response) + + response = hover_on_source(<<~RUBY, { line: 1, character: 26 }) + class Foo < ApplicationRecord + has_and_belongs_to_many :bars + end + + class Bar < ApplicationRecord + has_and_belongs_to_many :foos + end + RUBY + + assert_equal(<<~CONTENT.chomp, response.contents.value) + ```ruby + Bar + ``` + + **Definitions**: [fake.rb](file:///fake.rb#L5,1-7,4) + CONTENT + end + private def hover_on_source(source, position) diff --git a/test/ruby_lsp_rails/server_test.rb b/test/ruby_lsp_rails/server_test.rb index 571a20d7..ecd2e02b 100644 --- a/test/ruby_lsp_rails/server_test.rb +++ b/test/ruby_lsp_rails/server_test.rb @@ -55,43 +55,51 @@ def <(other) test "resolve association returns the location of the target class of a has_many association" do @server.execute( - "association_target_location", + "association_target", { model_name: "Organization", association_name: :memberships }, ) location = response[:result][:location] + name = response[:result][:name] + assert_equal "Membership", name assert_match %r{test/dummy/app/models/membership.rb:3$}, location end test "resolve association returns the location of the target class of a belongs_to association" do @server.execute( - "association_target_location", + "association_target", { model_name: "Membership", association_name: :organization }, ) location = response[:result][:location] + name = response[:result][:name] + assert_equal "Organization", name assert_match %r{test/dummy/app/models/organization.rb:3$}, location end test "resolve association returns the location of the target class of a has_one association" do @server.execute( - "association_target_location", + "association_target", { model_name: "User", association_name: :profile }, ) location = response[:result][:location] + name = response[:result][:name] + assert_equal "Profile", name assert_match %r{test/dummy/app/models/profile.rb:3$}, location end test "resolve association returns the location of the target class of a has_and_belongs_to_many association" do @server.execute( - "association_target_location", + "association_target", { model_name: "Profile", association_name: :labels }, ) location = response[:result][:location] + name = response[:result][:name] + assert_equal "Label", name assert_match %r{test/dummy/app/models/label.rb:3$}, location end test "resolve association handles invalid model name" do @server.execute( - "association_target_location", + "association_target", { model_name: "NotHere", association_name: :labels }, ) assert_nil(response.fetch(:result)) @@ -99,7 +107,7 @@ def <(other) test "resolve association handles invalid association name" do @server.execute( - "association_target_location", + "association_target", { model_name: "Membership", association_name: :labels }, ) assert_nil(response.fetch(:result)) @@ -107,10 +115,12 @@ def <(other) test "resolve association handles class_name option" do @server.execute( - "association_target_location", + "association_target", { model_name: "User", association_name: :location }, ) location = response[:result][:location] + name = response[:result][:name] + assert_equal "Country", name assert_match %r{test/dummy/app/models/country.rb:3$}, location end