Skip to content

Commit 098529a

Browse files
committed
Add hover support for :through associations
1 parent 48d44fd commit 098529a

File tree

2 files changed

+223
-12
lines changed

2 files changed

+223
-12
lines changed

lib/ruby_lsp/ruby_lsp_rails/hover.rb

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def initialize(client, response_builder, node_context, global_state, dispatcher)
2929
:on_constant_path_node_enter,
3030
:on_constant_read_node_enter,
3131
:on_symbol_node_enter,
32+
:on_string_node_enter,
3233
)
3334
end
3435

@@ -56,6 +57,11 @@ def on_symbol_node_enter(node)
5657
handle_possible_dsl(node)
5758
end
5859

60+
#: (Prism::StringNode node) -> void
61+
def on_string_node_enter(node)
62+
handle_possible_dsl(node)
63+
end
64+
5965
private
6066

6167
#: (String name) -> void
@@ -116,28 +122,67 @@ def format_default(default_value, type)
116122
end
117123
end
118124

119-
#: (Prism::SymbolNode node) -> void
125+
#: ((Prism::SymbolNode | Prism::StringNode) node) -> void
120126
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
127+
call_node = @node_context.call_node
128+
return unless call_node
129+
return unless self_receiver?(call_node)
126130

131+
message = call_node.message
127132
return unless message
128133

129134
if Support::Associations::ALL.include?(message)
130-
handle_association(node)
135+
handle_association(node, call_node)
136+
end
137+
end
138+
139+
#: ((Prism::SymbolNode | Prism::StringNode) node, Prism::CallNode call_node) -> void
140+
def handle_association(node, call_node)
141+
arguments = call_node.arguments&.arguments
142+
return unless arguments
143+
144+
first_argument = arguments.first
145+
return unless first_argument.is_a?(Prism::SymbolNode) || first_argument.is_a?(Prism::StringNode)
146+
147+
association_name = extract_string_from_node(first_argument)
148+
return unless association_name
149+
150+
through_element = find_through_association_element(arguments)
151+
clicked_symbol = extract_string_from_node(node)
152+
return unless clicked_symbol
153+
154+
if through_element
155+
through_association_name = extract_string_from_node(through_element.value)
156+
157+
if clicked_symbol == association_name
158+
handle_association_name(association_name)
159+
elsif through_association_name && clicked_symbol == through_association_name
160+
handle_association_name(through_association_name)
161+
end
162+
else
163+
handle_association_name(association_name)
131164
end
132165
end
133166

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)
167+
#: (Array[Prism::Node]) -> Prism::AssocNode?
168+
def find_through_association_element(arguments)
169+
result = arguments
170+
.filter_map { |arg| arg.elements if arg.is_a?(Prism::KeywordHashNode) }
171+
.flatten
172+
.find do |elem|
173+
next false unless elem.is_a?(Prism::AssocNode)
174+
175+
key = elem.key
176+
next false unless key.is_a?(Prism::SymbolNode)
138177

139-
association_name = first_argument.unescaped
178+
key.value == "through"
179+
end
140180

181+
result if result.is_a?(Prism::AssocNode)
182+
end
183+
184+
#: (String association_name) -> void
185+
def handle_association_name(association_name)
141186
result = @client.association_target(
142187
model_name: @nesting.join("::"),
143188
association_name: association_name,
@@ -164,6 +209,16 @@ def generate_hover(name)
164209
@response_builder.push(content, category: category)
165210
end
166211
end
212+
213+
#: (Prism::Node) -> String?
214+
def extract_string_from_node(node)
215+
case node
216+
when Prism::SymbolNode
217+
node.unescaped
218+
when Prism::StringNode
219+
node.content
220+
end
221+
end
167222
end
168223
end
169224
end

test/ruby_lsp_rails/hover_test.rb

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,162 @@ class Bar < ApplicationRecord
321321
CONTENT
322322
end
323323

324+
test "returns main association on has_many :through association" do
325+
expected_response = {
326+
location: "#{dummy_root}/app/models/user.rb:2",
327+
name: "User",
328+
}
329+
RunnerClient.any_instance.stubs(association_target: expected_response)
330+
331+
response = hover_on_source(<<~RUBY, { line: 2, character: 14 })
332+
class Organization < ApplicationRecord
333+
has_many :memberships
334+
has_many :users, through: :memberships
335+
end
336+
337+
class User < ApplicationRecord
338+
end
339+
RUBY
340+
341+
assert_equal(<<~CONTENT.chomp, response.contents.value)
342+
```ruby
343+
User
344+
```
345+
346+
**Definitions**: [fake.rb](file:///fake.rb#L6,1-7,4)
347+
CONTENT
348+
end
349+
350+
test "returns through association on has_many :through association" do
351+
expected_response = {
352+
location: "#{dummy_root}/app/models/membership.rb:3",
353+
name: "Membership",
354+
}
355+
RunnerClient.any_instance.stubs(association_target: expected_response)
356+
357+
response = hover_on_source(<<~RUBY, { line: 2, character: 31 })
358+
class Organization < ApplicationRecord
359+
has_many :memberships
360+
has_many :users, through: :memberships
361+
end
362+
363+
class Membership < ApplicationRecord
364+
end
365+
RUBY
366+
367+
assert_equal(<<~CONTENT.chomp, response.contents.value)
368+
```ruby
369+
Membership
370+
```
371+
372+
**Definitions**: [fake.rb](file:///fake.rb#L6,1-7,4)
373+
CONTENT
374+
end
375+
376+
test "returns main association on has_one :through association" do
377+
expected_response = {
378+
location: "#{dummy_root}/app/models/flag.rb:2",
379+
name: "Flag",
380+
}
381+
RunnerClient.any_instance.stubs(association_target: expected_response)
382+
383+
response = hover_on_source(<<~RUBY, { line: 2, character: 13 })
384+
class User < ApplicationRecord
385+
belongs_to :location, class_name: "Country"
386+
has_one :country_flag, through: :location, source: :flag
387+
end
388+
389+
class Flag < ApplicationRecord
390+
end
391+
RUBY
392+
393+
assert_equal(<<~CONTENT.chomp, response.contents.value)
394+
```ruby
395+
Flag
396+
```
397+
398+
**Definitions**: [fake.rb](file:///fake.rb#L6,1-7,4)
399+
CONTENT
400+
end
401+
402+
test "returns through association on has_one :through association" do
403+
expected_response = {
404+
location: "#{dummy_root}/app/models/country.rb:2",
405+
name: "Country",
406+
}
407+
RunnerClient.any_instance.stubs(association_target: expected_response)
408+
409+
response = hover_on_source(<<~RUBY, { line: 2, character: 37 })
410+
class User < ApplicationRecord
411+
belongs_to :location, class_name: "Country"
412+
has_one :country_flag, through: :location, source: :flag
413+
end
414+
415+
class Country < ApplicationRecord
416+
end
417+
RUBY
418+
419+
assert_equal(<<~CONTENT.chomp, response.contents.value)
420+
```ruby
421+
Country
422+
```
423+
424+
**Definitions**: [fake.rb](file:///fake.rb#L6,1-7,4)
425+
CONTENT
426+
end
427+
428+
test "returns string main association on has_many :through association" do
429+
expected_response = {
430+
location: "#{dummy_root}/app/models/user.rb:2",
431+
name: "User",
432+
}
433+
RunnerClient.any_instance.stubs(association_target: expected_response)
434+
435+
response = hover_on_source(<<~RUBY, { line: 2, character: 14 })
436+
class Organization < ApplicationRecord
437+
has_many :memberships
438+
has_many "users", through: :memberships
439+
end
440+
441+
class User < ApplicationRecord
442+
end
443+
RUBY
444+
445+
assert_equal(<<~CONTENT.chomp, response.contents.value)
446+
```ruby
447+
User
448+
```
449+
450+
**Definitions**: [fake.rb](file:///fake.rb#L6,1-7,4)
451+
CONTENT
452+
end
453+
454+
test "returns string through association on has_many :through association" do
455+
expected_response = {
456+
location: "#{dummy_root}/app/models/membership.rb:3",
457+
name: "Membership",
458+
}
459+
RunnerClient.any_instance.stubs(association_target: expected_response)
460+
461+
response = hover_on_source(<<~RUBY, { line: 2, character: 32 })
462+
class Organization < ApplicationRecord
463+
has_many "memberships"
464+
has_many :users, through: "memberships"
465+
end
466+
467+
class Membership < ApplicationRecord
468+
end
469+
RUBY
470+
471+
assert_equal(<<~CONTENT.chomp, response.contents.value)
472+
```ruby
473+
Membership
474+
```
475+
476+
**Definitions**: [fake.rb](file:///fake.rb#L6,1-7,4)
477+
CONTENT
478+
end
479+
324480
private
325481

326482
def hover_on_source(source, position)

0 commit comments

Comments
 (0)