Skip to content

Commit 0c85e7a

Browse files
Add support for jump-to-def for associations (#373)
* WIP: has_many POC * Add belongs to * Add has_one * Add has_one_and_belongs_to_many * Move association constant to separate support module * Rubocop fixes * WIP try to track class name on class enter * refactor: use nesting for class name instead of hardcoding * fix: handle invalid association name in server * refactor: add location builder support class * refactor: remove unused node lifecycles * Add a class_name example * Use reflect_on_association * PR feedback * Apply suggestions from code review Co-authored-by: Vinicius Stock <[email protected]> * Rubocop --------- Co-authored-by: Vinicius Stock <[email protected]>
1 parent d5c92e8 commit 0c85e7a

17 files changed

+338
-15
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
require_relative "../../ruby_lsp_rails/version"
77
require_relative "support/active_support_test_case_helper"
8+
require_relative "support/associations"
89
require_relative "support/callbacks"
10+
require_relative "support/location_builder"
911
require_relative "runner_client"
1012
require_relative "hover"
1113
require_relative "code_lens"

lib/ruby_lsp/ruby_lsp_rails/definition.rb

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def on_call_node_enter(node)
5757

5858
return unless message
5959

60-
if Support::Callbacks::ALL.include?(message)
60+
if Support::Associations::ALL.include?(message)
61+
handle_association(node)
62+
elsif Support::Callbacks::ALL.include?(message)
6163
handle_callback(node)
6264
elsif message.end_with?("_path") || message.end_with?("_url")
6365
handle_route(node)
@@ -86,23 +88,28 @@ def handle_callback(node)
8688
end
8789

8890
sig { params(node: Prism::CallNode).void }
89-
def handle_route(node)
90-
result = @client.route_location(T.must(node.message))
91+
def handle_association(node)
92+
first_argument = node.arguments&.arguments&.first
93+
return unless first_argument.is_a?(Prism::SymbolNode)
94+
95+
association_name = first_argument.unescaped
96+
97+
result = @client.association_target_location(
98+
model_name: @nesting.join("::"),
99+
association_name: association_name,
100+
)
101+
91102
return unless result
92103

93-
*file_parts, line = result.fetch(:location).split(":")
104+
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
105+
end
94106

95-
# On Windows, file paths will look something like `C:/path/to/file.rb:123`. Only the last colon is the line
96-
# number and all other parts compose the file path
97-
file_path = file_parts.join(":")
107+
sig { params(node: Prism::CallNode).void }
108+
def handle_route(node)
109+
result = @client.route_location(T.must(node.message))
110+
return unless result
98111

99-
@response_builder << Interface::Location.new(
100-
uri: URI::Generic.from_path(path: file_path).to_s,
101-
range: Interface::Range.new(
102-
start: Interface::Position.new(line: Integer(line) - 1, character: 0),
103-
end: Interface::Position.new(line: Integer(line) - 1, character: 0),
104-
),
105-
)
112+
@response_builder << Support::LocationBuilder.line_location_from_s(result.fetch(:location))
106113
end
107114

108115
sig { params(name: String).void }

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ def model(name)
9898
nil
9999
end
100100

101+
sig do
102+
params(
103+
model_name: String,
104+
association_name: String,
105+
).returns(T.nilable(T::Hash[Symbol, T.untyped]))
106+
end
107+
def association_target_location(model_name:, association_name:)
108+
make_request(
109+
"association_target_location",
110+
model_name: model_name,
111+
association_name: association_name,
112+
)
113+
rescue => e
114+
$stderr.puts("Ruby LSP Rails failed with #{e.message}: #{@stderr.read}")
115+
end
116+
101117
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
102118
def route_location(name)
103119
make_request("route_location", name: name)

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ def execute(request, params)
4747
VOID
4848
when "model"
4949
resolve_database_info_from_model(params.fetch(:name))
50+
when "association_target_location"
51+
resolve_association_target(params)
5052
when "reload"
5153
::Rails.application.reloader.reload!
5254
VOID
@@ -114,6 +116,29 @@ def resolve_database_info_from_model(model_name)
114116
{ error: e.full_message(highlight: false) }
115117
end
116118

119+
def resolve_association_target(params)
120+
const = ActiveSupport::Inflector.safe_constantize(params[:model_name])
121+
unless active_record_model?(const)
122+
return {
123+
result: nil,
124+
}
125+
end
126+
127+
association_klass = const.reflect_on_association(params[:association_name].intern).klass
128+
129+
source_location = Object.const_source_location(association_klass.to_s)
130+
131+
{
132+
result: {
133+
location: source_location.first + ":" + source_location.second.to_s,
134+
},
135+
}
136+
rescue NameError
137+
{
138+
result: nil,
139+
}
140+
end
141+
117142
def active_record_model?(const)
118143
!!(
119144
const &&
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
module Support
7+
module Associations
8+
ALL = T.let(
9+
[
10+
"belongs_to",
11+
"has_many",
12+
"has_one",
13+
"has_and_belongs_to_many",
14+
].freeze,
15+
T::Array[String],
16+
)
17+
end
18+
end
19+
end
20+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
module Support
7+
class LocationBuilder
8+
class << self
9+
extend T::Sig
10+
11+
sig { params(location_string: String).returns(Interface::Location) }
12+
def line_location_from_s(location_string)
13+
*file_parts, line = location_string.split(":")
14+
15+
raise ArgumentError, "Invalid location string given" unless file_parts
16+
17+
# On Windows, file paths will look something like `C:/path/to/file.rb:123`. Only the last colon is the line
18+
# number and all other parts compose the file path
19+
file_path = file_parts.join(":")
20+
21+
Interface::Location.new(
22+
uri: URI::Generic.from_path(path: file_path).to_s,
23+
range: Interface::Range.new(
24+
start: Interface::Position.new(line: Integer(line) - 1, character: 0),
25+
end: Interface::Position.new(line: Integer(line) - 1, character: 0),
26+
),
27+
)
28+
end
29+
end
30+
end
31+
end
32+
end
33+
end

test/dummy/app/models/country.rb

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

test/dummy/app/models/label.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 Label < ApplicationRecord
4+
has_and_belongs_to_many :profiles
5+
end

test/dummy/app/models/profile.rb

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

test/dummy/app/models/user.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ 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"
89

910
attr_readonly :last_name
1011

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class CreateCountries < ActiveRecord::Migration[7.1]
2+
def change
3+
create_table :countries do |t|
4+
t.string :name
5+
6+
t.timestamps
7+
end
8+
end
9+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddCountryToUser < ActiveRecord::Migration[7.1]
2+
def change
3+
add_reference :users, :country, null: false, foreign_key: true
4+
end
5+
end

test/dummy/db/schema.rb

Lines changed: 10 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[7.1].define(version: 2024_05_03_153021) do
13+
ActiveRecord::Schema[7.1].define(version: 2024_05_21_183200) 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"
@@ -19,6 +19,12 @@
1919
t.datetime "updated_at", null: false
2020
end
2121

22+
create_table "countries", force: :cascade do |t|
23+
t.string "name"
24+
t.datetime "created_at", null: false
25+
t.datetime "updated_at", null: false
26+
end
27+
2228
create_table "memberships", force: :cascade do |t|
2329
t.integer "user_id", null: false
2430
t.integer "organization_id", null: false
@@ -47,8 +53,11 @@
4753
t.integer "age"
4854
t.datetime "created_at", null: false
4955
t.datetime "updated_at", null: false
56+
t.integer "country_id", null: false
57+
t.index ["country_id"], name: "index_users_on_country_id"
5058
end
5159

5260
add_foreign_key "memberships", "organizations"
5361
add_foreign_key "memberships", "users"
62+
add_foreign_key "users", "countries"
5463
end

test/ruby_lsp_rails/definition_test.rb

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,101 @@ def baz; end
3333
assert_equal(14, response[1].range.end.character)
3434
end
3535

36+
test "recognizes has_many model associations" do
37+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 4 })
38+
# typed: false
39+
40+
class Organization < ActiveRecord::Base
41+
has_many :memberships
42+
end
43+
RUBY
44+
45+
assert_equal(1, response.size)
46+
47+
assert_equal(
48+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "membership.rb")).to_s,
49+
response[0].uri,
50+
)
51+
assert_equal(2, response[0].range.start.line)
52+
assert_equal(2, response[0].range.end.line)
53+
end
54+
55+
test "recognizes belongs_to model associations" do
56+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 4 })
57+
# typed: false
58+
59+
class Membership < ActiveRecord::Base
60+
belongs_to :organization
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", "organization.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 has_one model associations" do
75+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 4 })
76+
# typed: false
77+
78+
class User < ActiveRecord::Base
79+
has_one :profile
80+
end
81+
RUBY
82+
83+
assert_equal(1, response.size)
84+
85+
assert_equal(
86+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "profile.rb")).to_s,
87+
response[0].uri,
88+
)
89+
assert_equal(2, response[0].range.start.line)
90+
assert_equal(2, response[0].range.end.line)
91+
end
92+
93+
test "recognizes has_and_belongs_to_many model associations" do
94+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 4 })
95+
# typed: false
96+
97+
class Profile < ActiveRecord::Base
98+
has_and_belongs_to_many :labels
99+
end
100+
RUBY
101+
102+
assert_equal(1, response.size)
103+
104+
assert_equal(
105+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "label.rb")).to_s,
106+
response[0].uri,
107+
)
108+
assert_equal(2, response[0].range.start.line)
109+
assert_equal(2, response[0].range.end.line)
110+
end
111+
112+
test "handles class_name argument for associations" do
113+
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 4 })
114+
# typed: false
115+
116+
class User < ActiveRecord::Base
117+
has_one :location, class_name: "Country"
118+
end
119+
RUBY
120+
121+
assert_equal(1, response.size)
122+
123+
assert_equal(
124+
URI::Generic.from_path(path: File.join(dummy_root, "app", "models", "country.rb")).to_s,
125+
response[0].uri,
126+
)
127+
assert_equal(2, response[0].range.start.line)
128+
assert_equal(2, response[0].range.end.line)
129+
end
130+
36131
test "recognizes controller callback with string argument" do
37132
response = generate_definitions_for_source(<<~RUBY, { line: 3, character: 10 })
38133
# typed: false

test/ruby_lsp_rails/runner_client_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class RunnerClientTest < ActiveSupport::TestCase
3030
["age", "integer"],
3131
["created_at", "datetime"],
3232
["updated_at", "datetime"],
33+
["country_id", "integer"],
3334
]
3435
response = T.must(@client.model("User"))
3536
assert_equal(columns, response.fetch(:columns))

0 commit comments

Comments
 (0)