Skip to content

Add hover information on associations #616

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/ruby_lsp/ruby_lsp_rails/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
64 changes: 63 additions & 1 deletion lib/ruby_lsp/ruby_lsp_rails/hover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/ruby_lsp/ruby_lsp_rails/runner_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
4 changes: 2 additions & 2 deletions lib/ruby_lsp/ruby_lsp_rails/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions test/ruby_lsp_rails/hover_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 17 additions & 7 deletions test/ruby_lsp_rails/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,62 +55,72 @@ 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))
end

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))
end

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

Expand Down