Skip to content

Commit 972fab5

Browse files
committed
Add definition support for :through associations
1 parent 1b75ae3 commit 972fab5

File tree

7 files changed

+198
-11
lines changed

7 files changed

+198
-11
lines changed

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def handle_possible_dsl(node)
8080
return unless arguments
8181

8282
if Support::Associations::ALL.include?(message)
83-
handle_association(call_node)
83+
handle_association(node, call_node)
8484
elsif Support::Callbacks::ALL.include?(message)
8585
handle_callback(node, call_node, arguments)
8686
handle_if_unless_conditional(node, call_node, arguments)
@@ -125,18 +125,57 @@ def handle_validation(node, call_node, arguments)
125125
collect_definitions(name)
126126
end
127127

128-
#: (Prism::CallNode node) -> void
129-
def handle_association(node)
130-
first_argument = node.arguments&.arguments&.first
131-
return unless first_argument.is_a?(Prism::SymbolNode)
128+
#: ((Prism::SymbolNode | Prism::StringNode) node, Prism::CallNode call_node) -> void
129+
def handle_association(node, call_node)
130+
arguments = call_node.arguments&.arguments
131+
return unless arguments
132+
133+
first_argument = arguments.first
134+
return unless first_argument.is_a?(Prism::SymbolNode) || first_argument.is_a?(Prism::StringNode)
135+
136+
association_name = extract_string_from_node(first_argument)
137+
return unless association_name
138+
139+
through_element = find_through_association_element(arguments)
140+
clicked_symbol = extract_string_from_node(node)
141+
return unless clicked_symbol
142+
143+
if through_element
144+
through_association_name = extract_string_from_node(through_element.value)
145+
146+
if clicked_symbol == association_name
147+
handle_association_name(association_name)
148+
elsif through_association_name && clicked_symbol == through_association_name
149+
handle_association_name(through_association_name)
150+
end
151+
else
152+
handle_association_name(association_name)
153+
end
154+
end
132155

133-
association_name = first_argument.unescaped
156+
#: (Array[Prism::Node]) -> Prism::AssocNode?
157+
def find_through_association_element(arguments)
158+
result = arguments
159+
.filter_map { |arg| arg.elements if arg.is_a?(Prism::KeywordHashNode) }
160+
.flatten
161+
.find do |elem|
162+
next false unless elem.is_a?(Prism::AssocNode)
134163

164+
key = elem.key
165+
next false unless key.is_a?(Prism::SymbolNode)
166+
167+
key.value == "through"
168+
end
169+
170+
result if result.is_a?(Prism::AssocNode)
171+
end
172+
173+
#: (String association_name) -> void
174+
def handle_association_name(association_name)
135175
result = @client.association_target(
136176
model_name: @nesting.join("::"),
137177
association_name: association_name,
138178
)
139-
140179
return unless result
141180

142181
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
@@ -194,6 +233,16 @@ def handle_if_unless_conditional(node, call_node, arguments)
194233

195234
collect_definitions(method_name)
196235
end
236+
237+
#: (Prism::Node) -> String?
238+
def extract_string_from_node(node)
239+
case node
240+
when Prism::SymbolNode
241+
node.unescaped
242+
when Prism::StringNode
243+
node.content
244+
end
245+
end
197246
end
198247
end
199248
end

test/dummy/app/models/country.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# frozen_string_literal: true
22

33
class Country < ApplicationRecord
4+
has_one :flag, dependent: :destroy
45
end

test/dummy/app/models/flag.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class Flag < ApplicationRecord
4+
belongs_to :country
5+
end

test/dummy/app/models/user.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ class User < ApplicationRecord
55
validates :first_name, presence: true
66
has_one :profile
77
scope :adult, -> { where(age: 18..) }
8-
has_one :location, class_name: "Country"
8+
belongs_to :location, class_name: "Country"
9+
has_one :country_flag, through: :location, source: :flag
910

1011
attr_readonly :last_name
1112

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class CreateFlags < ActiveRecord::Migration[8.0]
2+
def change
3+
create_table :flags do |t|
4+
t.references :country, null: false, foreign_key: true
5+
6+
t.timestamps
7+
end
8+
end
9+
end

test/dummy/db/schema.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.0].define(version: 2024_10_25_225348) do
13+
ActiveRecord::Schema[8.0].define(version: 2025_07_03_132109) do
1414
create_table "composite_primary_keys", primary_key: ["order_id", "product_id"], force: :cascade do |t|
1515
t.integer "order_id"
1616
t.integer "product_id"
@@ -25,6 +25,13 @@
2525
t.datetime "updated_at", null: false
2626
end
2727

28+
create_table "flags", force: :cascade do |t|
29+
t.integer "country_id", null: false
30+
t.datetime "created_at", null: false
31+
t.datetime "updated_at", null: false
32+
t.index ["country_id"], name: "index_flags_on_country_id"
33+
end
34+
2835
create_table "memberships", force: :cascade do |t|
2936
t.integer "user_id", null: false
3037
t.integer "organization_id", null: false
@@ -58,6 +65,7 @@
5865
t.index ["country_id"], name: "index_users_on_country_id"
5966
end
6067

68+
add_foreign_key "flags", "countries"
6169
add_foreign_key "memberships", "organizations"
6270
add_foreign_key "memberships", "users"
6371
add_foreign_key "users", "countries"

test/ruby_lsp_rails/definition_test.rb

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,42 @@ class Organization < ActiveRecord::Base
5353
assert_equal(2, response[0].range.end.line)
5454
end
5555

56+
test "recognizes main association on has_many :through association" do
57+
response = generate_definitions_for_source(<<~RUBY, { line: 2, character: 13 })
58+
class Organization < ActiveRecord::Base
59+
has_many :memberships
60+
has_many :users, through: :memberships
61+
end
62+
RUBY
63+
64+
assert_equal(1, response.size)
65+
66+
assert_equal(
67+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "user.rb")).to_s,
68+
response[0].uri,
69+
)
70+
assert_equal(2, response[0].range.start.line)
71+
assert_equal(2, response[0].range.end.line)
72+
end
73+
74+
test "recognizes through association on has_many :through association" do
75+
response = generate_definitions_for_source(<<~RUBY, { line: 2, character: 30 })
76+
class Organization < ActiveRecord::Base
77+
has_many :memberships
78+
has_many :users, through: :memberships
79+
end
80+
RUBY
81+
82+
assert_equal(1, response.size)
83+
84+
assert_equal(
85+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "membership.rb")).to_s,
86+
response[0].uri,
87+
)
88+
assert_equal(2, response[0].range.start.line)
89+
assert_equal(2, response[0].range.end.line)
90+
end
91+
5692
test "recognizes belongs_to model associations" do
5793
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 14 })
5894
# typed: false
@@ -91,6 +127,42 @@ class User < ActiveRecord::Base
91127
assert_equal(2, response[0].range.end.line)
92128
end
93129

130+
test "recognizes main association on has_one :through association" do
131+
response = generate_definitions_for_source(<<~RUBY, { line: 2, character: 12 })
132+
class User < ActiveRecord::Base
133+
belongs_to :location, class_name: "Country"
134+
has_one :country_flag, through: :location, source: :flag
135+
end
136+
RUBY
137+
138+
assert_equal(1, response.size)
139+
140+
assert_equal(
141+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "flag.rb")).to_s,
142+
response[0].uri,
143+
)
144+
assert_equal(2, response[0].range.start.line)
145+
assert_equal(2, response[0].range.end.line)
146+
end
147+
148+
test "recognizes through association on has_one :through association" do
149+
response = generate_definitions_for_source(<<~RUBY, { line: 2, character: 36 })
150+
class User < ActiveRecord::Base
151+
belongs_to :location, class_name: "Country"
152+
has_one :country_flag, through: :location, source: :flag
153+
end
154+
RUBY
155+
156+
assert_equal(1, response.size)
157+
158+
assert_equal(
159+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "country.rb")).to_s,
160+
response[0].uri,
161+
)
162+
assert_equal(2, response[0].range.start.line)
163+
assert_equal(2, response[0].range.end.line)
164+
end
165+
94166
test "recognizes has_and_belongs_to_many model associations" do
95167
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 27 })
96168
# typed: false
@@ -111,11 +183,11 @@ class Profile < ActiveRecord::Base
111183
end
112184

113185
test "handles class_name argument for associations" do
114-
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 11 })
186+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 14 })
115187
# typed: false
116188
117189
class User < ActiveRecord::Base
118-
has_one :location, class_name: "Country"
190+
belongs_to :location, class_name: "Country"
119191
end
120192
RUBY
121193

@@ -467,6 +539,48 @@ def name; end
467539
assert_equal(15, response.range.end.character)
468540
end
469541

542+
test "recognizes string main association on has_many :through association" do
543+
response = generate_definitions_for_source(<<~RUBY, { line: 2, character: 14 })
544+
class Organization < ApplicationRecord
545+
has_many :memberships
546+
has_many "users", through: :memberships
547+
end
548+
549+
class User < ApplicationRecord
550+
end
551+
RUBY
552+
553+
assert_equal(1, response.size)
554+
555+
assert_equal(
556+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "user.rb")).to_s,
557+
response[0].uri,
558+
)
559+
assert_equal(2, response[0].range.start.line)
560+
assert_equal(2, response[0].range.end.line)
561+
end
562+
563+
test "recognizes string through association on has_many :through association" do
564+
response = generate_definitions_for_source(<<~RUBY, { line: 2, character: 32 })
565+
class Organization < ApplicationRecord
566+
has_many "memberships"
567+
has_many :users, through: "memberships"
568+
end
569+
570+
class Membership < ApplicationRecord
571+
end
572+
RUBY
573+
574+
assert_equal(1, response.size)
575+
576+
assert_equal(
577+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "membership.rb")).to_s,
578+
response[0].uri,
579+
)
580+
assert_equal(2, response[0].range.start.line)
581+
assert_equal(2, response[0].range.end.line)
582+
end
583+
470584
private
471585

472586
def generate_definitions_for_source(source, position)

0 commit comments

Comments
 (0)