Skip to content

Commit 2e64213

Browse files
authored
Add hover information on associations (#616)
1 parent 7fe21f6 commit 2e64213

File tree

6 files changed

+186
-13
lines changed

6 files changed

+186
-13
lines changed

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def handle_association(node)
109109

110110
association_name = first_argument.unescaped
111111

112-
result = @client.association_target_location(
112+
result = @client.association_target(
113113
model_name: @nesting.join("::"),
114114
association_name: association_name,
115115
)

lib/ruby_lsp/ruby_lsp_rails/hover.rb

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ class Hover
2121
def initialize(client, response_builder, node_context, global_state, dispatcher)
2222
@client = client
2323
@response_builder = response_builder
24+
@node_context = node_context
2425
@nesting = node_context.nesting #: Array[String]
2526
@index = global_state.index #: RubyIndexer::Index
26-
dispatcher.register(self, :on_constant_path_node_enter, :on_constant_read_node_enter)
27+
dispatcher.register(
28+
self,
29+
:on_constant_path_node_enter,
30+
:on_constant_read_node_enter,
31+
:on_symbol_node_enter,
32+
)
2733
end
2834

2935
#: (Prism::ConstantPathNode node) -> void
@@ -45,6 +51,11 @@ def on_constant_read_node_enter(node)
4551
generate_column_content(item.name)
4652
end
4753

54+
#: (Prism::SymbolNode node) -> void
55+
def on_symbol_node_enter(node)
56+
handle_possible_dsl(node)
57+
end
58+
4859
private
4960

5061
#: (String name) -> void
@@ -104,6 +115,55 @@ def format_default(default_value, type)
104115
default_value
105116
end
106117
end
118+
119+
#: (Prism::SymbolNode node) -> void
120+
def handle_possible_dsl(node)
121+
node = @node_context.call_node
122+
return unless node
123+
return unless self_receiver?(node)
124+
125+
message = node.message
126+
127+
return unless message
128+
129+
if Support::Associations::ALL.include?(message)
130+
handle_association(node)
131+
end
132+
end
133+
134+
#: (Prism::CallNode node) -> void
135+
def handle_association(node)
136+
first_argument = node.arguments&.arguments&.first
137+
return unless first_argument.is_a?(Prism::SymbolNode)
138+
139+
association_name = first_argument.unescaped
140+
141+
result = @client.association_target(
142+
model_name: @nesting.join("::"),
143+
association_name: association_name,
144+
)
145+
146+
return unless result
147+
148+
generate_hover(result[:name])
149+
end
150+
151+
# Copied from `RubyLsp::Listeners::Hover#generate_hover`
152+
#: (String name) -> void
153+
def generate_hover(name)
154+
entries = @index.resolve(name, @node_context.nesting)
155+
return unless entries
156+
157+
# We should only show hover for private constants if the constant is defined in the same namespace as the
158+
# reference
159+
first_entry = entries.first #: as !nil
160+
full_name = first_entry.name
161+
return if first_entry.private? && full_name != "#{@node_context.fully_qualified_name}::#{name}"
162+
163+
categorized_markdown_from_index_entries(full_name, entries).each do |category, content|
164+
@response_builder.push(content, category: category)
165+
end
166+
end
107167
end
108168
end
109169
end

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ def model(name)
144144
end
145145

146146
#: (model_name: String, association_name: String) -> Hash[Symbol, untyped]?
147-
def association_target_location(model_name:, association_name:)
147+
def association_target(model_name:, association_name:)
148148
make_request(
149-
"association_target_location",
149+
"association_target",
150150
model_name: model_name,
151151
association_name: association_name,
152152
)

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ def execute(request, params)
306306
with_request_error_handling(request) do
307307
send_result(resolve_database_info_from_model(params.fetch(:name)))
308308
end
309-
when "association_target_location"
309+
when "association_target"
310310
with_request_error_handling(request) do
311311
send_result(resolve_association_target(params))
312312
end
@@ -431,7 +431,7 @@ def resolve_association_target(params)
431431
source_location = Object.const_source_location(association_klass.to_s)
432432
return unless source_location
433433

434-
{ location: "#{source_location[0]}:#{source_location[1]}" }
434+
{ location: "#{source_location[0]}:#{source_location[1]}", name: association_klass.name }
435435
rescue NameError
436436
nil
437437
end

test/ruby_lsp_rails/hover_test.rb

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,109 @@ class User < ApplicationRecord
218218
refute_match(/Schema/, response.contents.value)
219219
end
220220

221+
test "returns has_many association information" do
222+
expected_response = {
223+
location: "#{dummy_root}/app/models/membership.rb:5",
224+
name: "Bar",
225+
}
226+
RunnerClient.any_instance.stubs(association_target: expected_response)
227+
228+
response = hover_on_source(<<~RUBY, { line: 1, character: 11 })
229+
class Foo < ApplicationRecord
230+
has_many :bars
231+
end
232+
233+
class Bar < ApplicationRecord
234+
belongs_to :foo
235+
end
236+
RUBY
237+
238+
assert_equal(<<~CONTENT.chomp, response.contents.value)
239+
```ruby
240+
Bar
241+
```
242+
243+
**Definitions**: [fake.rb](file:///fake.rb#L5,1-7,4)
244+
CONTENT
245+
end
246+
247+
test "returns belongs_to association information" do
248+
expected_response = {
249+
location: "#{dummy_root}/app/models/membership.rb:1",
250+
name: "Foo",
251+
}
252+
RunnerClient.any_instance.stubs(association_target: expected_response)
253+
254+
response = hover_on_source(<<~RUBY, { line: 5, character: 14 })
255+
class Foo < ApplicationRecord
256+
has_many :bars
257+
end
258+
259+
class Bar < ApplicationRecord
260+
belongs_to :foo
261+
end
262+
RUBY
263+
264+
assert_equal(<<~CONTENT.chomp, response.contents.value)
265+
```ruby
266+
Foo
267+
```
268+
269+
**Definitions**: [fake.rb](file:///fake.rb#L1,1-3,4)
270+
CONTENT
271+
end
272+
273+
test "returns has_one association information" do
274+
expected_response = {
275+
location: "#{dummy_root}/app/models/membership.rb:5",
276+
name: "Bar",
277+
}
278+
RunnerClient.any_instance.stubs(association_target: expected_response)
279+
280+
response = hover_on_source(<<~RUBY, { line: 1, character: 10 })
281+
class Foo < ApplicationRecord
282+
has_one :bar
283+
end
284+
285+
class Bar < ApplicationRecord
286+
end
287+
RUBY
288+
289+
assert_equal(<<~CONTENT.chomp, response.contents.value)
290+
```ruby
291+
Bar
292+
```
293+
294+
**Definitions**: [fake.rb](file:///fake.rb#L5,1-6,4)
295+
CONTENT
296+
end
297+
298+
test "returns has_and_belongs_to association information" do
299+
expected_response = {
300+
location: "#{dummy_root}/app/models/membership.rb:5",
301+
name: "Bar",
302+
}
303+
RunnerClient.any_instance.stubs(association_target: expected_response)
304+
305+
response = hover_on_source(<<~RUBY, { line: 1, character: 26 })
306+
class Foo < ApplicationRecord
307+
has_and_belongs_to_many :bars
308+
end
309+
310+
class Bar < ApplicationRecord
311+
has_and_belongs_to_many :foos
312+
end
313+
RUBY
314+
315+
assert_equal(<<~CONTENT.chomp, response.contents.value)
316+
```ruby
317+
Bar
318+
```
319+
320+
**Definitions**: [fake.rb](file:///fake.rb#L5,1-7,4)
321+
CONTENT
322+
end
323+
221324
private
222325

223326
def hover_on_source(source, position)

test/ruby_lsp_rails/server_test.rb

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,62 +55,72 @@ def <(other)
5555

5656
test "resolve association returns the location of the target class of a has_many association" do
5757
@server.execute(
58-
"association_target_location",
58+
"association_target",
5959
{ model_name: "Organization", association_name: :memberships },
6060
)
6161
location = response[:result][:location]
62+
name = response[:result][:name]
63+
assert_equal "Membership", name
6264
assert_match %r{test/dummy/app/models/membership.rb:3$}, location
6365
end
6466

6567
test "resolve association returns the location of the target class of a belongs_to association" do
6668
@server.execute(
67-
"association_target_location",
69+
"association_target",
6870
{ model_name: "Membership", association_name: :organization },
6971
)
7072
location = response[:result][:location]
73+
name = response[:result][:name]
74+
assert_equal "Organization", name
7175
assert_match %r{test/dummy/app/models/organization.rb:3$}, location
7276
end
7377

7478
test "resolve association returns the location of the target class of a has_one association" do
7579
@server.execute(
76-
"association_target_location",
80+
"association_target",
7781
{ model_name: "User", association_name: :profile },
7882
)
7983
location = response[:result][:location]
84+
name = response[:result][:name]
85+
assert_equal "Profile", name
8086
assert_match %r{test/dummy/app/models/profile.rb:3$}, location
8187
end
8288

8389
test "resolve association returns the location of the target class of a has_and_belongs_to_many association" do
8490
@server.execute(
85-
"association_target_location",
91+
"association_target",
8692
{ model_name: "Profile", association_name: :labels },
8793
)
8894
location = response[:result][:location]
95+
name = response[:result][:name]
96+
assert_equal "Label", name
8997
assert_match %r{test/dummy/app/models/label.rb:3$}, location
9098
end
9199

92100
test "resolve association handles invalid model name" do
93101
@server.execute(
94-
"association_target_location",
102+
"association_target",
95103
{ model_name: "NotHere", association_name: :labels },
96104
)
97105
assert_nil(response.fetch(:result))
98106
end
99107

100108
test "resolve association handles invalid association name" do
101109
@server.execute(
102-
"association_target_location",
110+
"association_target",
103111
{ model_name: "Membership", association_name: :labels },
104112
)
105113
assert_nil(response.fetch(:result))
106114
end
107115

108116
test "resolve association handles class_name option" do
109117
@server.execute(
110-
"association_target_location",
118+
"association_target",
111119
{ model_name: "User", association_name: :location },
112120
)
113121
location = response[:result][:location]
122+
name = response[:result][:name]
123+
assert_equal "Country", name
114124
assert_match %r{test/dummy/app/models/country.rb:3$}, location
115125
end
116126

0 commit comments

Comments
 (0)