Skip to content
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
63 changes: 56 additions & 7 deletions lib/ruby_lsp/ruby_lsp_rails/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def handle_possible_dsl(node)
return unless arguments

if Support::Associations::ALL.include?(message)
handle_association(call_node)
handle_association(node, call_node)
elsif Support::Callbacks::ALL.include?(message)
handle_callback(node, call_node, arguments)
handle_if_unless_conditional(node, call_node, arguments)
Expand Down Expand Up @@ -125,18 +125,57 @@ def handle_validation(node, call_node, arguments)
collect_definitions(name)
end

#: (Prism::CallNode node) -> void
def handle_association(node)
first_argument = node.arguments&.arguments&.first
return unless first_argument.is_a?(Prism::SymbolNode)
#: ((Prism::SymbolNode | Prism::StringNode) node, Prism::CallNode call_node) -> void
def handle_association(node, call_node)
arguments = call_node.arguments&.arguments
return unless arguments

first_argument = arguments.first
return unless first_argument.is_a?(Prism::SymbolNode) || first_argument.is_a?(Prism::StringNode)

association_name = extract_string_from_node(first_argument)
return unless association_name

through_element = find_through_association_element(arguments)
clicked_symbol = extract_string_from_node(node)
return unless clicked_symbol

if through_element
through_association_name = extract_string_from_node(through_element.value)

if clicked_symbol == association_name
handle_association_name(association_name)
elsif through_association_name && clicked_symbol == through_association_name
handle_association_name(through_association_name)
end
else
handle_association_name(association_name)
end
end

association_name = first_argument.unescaped
#: (Array[Prism::Node]) -> Prism::AssocNode?
def find_through_association_element(arguments)
result = arguments
.filter_map { |arg| arg.elements if arg.is_a?(Prism::KeywordHashNode) }
.flatten
.find do |elem|
next false unless elem.is_a?(Prism::AssocNode)

key = elem.key
next false unless key.is_a?(Prism::SymbolNode)

key.value == "through"
end

result if result.is_a?(Prism::AssocNode)
end

#: (String association_name) -> void
def handle_association_name(association_name)
result = @client.association_target(
model_name: @nesting.join("::"),
association_name: association_name,
)

return unless result

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

collect_definitions(method_name)
end

#: (Prism::Node) -> String?
def extract_string_from_node(node)
case node
when Prism::SymbolNode
node.unescaped
when Prism::StringNode
node.content
end
end
end
end
end
79 changes: 67 additions & 12 deletions lib/ruby_lsp/ruby_lsp_rails/hover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def initialize(client, response_builder, node_context, global_state, dispatcher)
:on_constant_path_node_enter,
:on_constant_read_node_enter,
:on_symbol_node_enter,
:on_string_node_enter,
)
end

Expand Down Expand Up @@ -56,6 +57,11 @@ def on_symbol_node_enter(node)
handle_possible_dsl(node)
end

#: (Prism::StringNode node) -> void
def on_string_node_enter(node)
handle_possible_dsl(node)
end

private

#: (String name) -> void
Expand Down Expand Up @@ -116,28 +122,67 @@ def format_default(default_value, type)
end
end

#: (Prism::SymbolNode node) -> void
#: ((Prism::SymbolNode | Prism::StringNode) node) -> void
def handle_possible_dsl(node)
node = @node_context.call_node
return unless node
return unless self_receiver?(node)

message = node.message
call_node = @node_context.call_node
return unless call_node
return unless self_receiver?(call_node)

message = call_node.message
return unless message

if Support::Associations::ALL.include?(message)
handle_association(node)
handle_association(node, call_node)
end
end

#: ((Prism::SymbolNode | Prism::StringNode) node, Prism::CallNode call_node) -> void
def handle_association(node, call_node)
arguments = call_node.arguments&.arguments
return unless arguments

first_argument = arguments.first
return unless first_argument.is_a?(Prism::SymbolNode) || first_argument.is_a?(Prism::StringNode)

association_name = extract_string_from_node(first_argument)
return unless association_name

through_element = find_through_association_element(arguments)
clicked_symbol = extract_string_from_node(node)
return unless clicked_symbol

if through_element
through_association_name = extract_string_from_node(through_element.value)

if clicked_symbol == association_name
handle_association_name(association_name)
elsif through_association_name && clicked_symbol == through_association_name
handle_association_name(through_association_name)
end
else
handle_association_name(association_name)
end
end

#: (Prism::CallNode node) -> void
def handle_association(node)
first_argument = node.arguments&.arguments&.first
return unless first_argument.is_a?(Prism::SymbolNode)
#: (Array[Prism::Node]) -> Prism::AssocNode?
def find_through_association_element(arguments)
result = arguments
.filter_map { |arg| arg.elements if arg.is_a?(Prism::KeywordHashNode) }
.flatten
.find do |elem|
next false unless elem.is_a?(Prism::AssocNode)

key = elem.key
next false unless key.is_a?(Prism::SymbolNode)

association_name = first_argument.unescaped
key.value == "through"
end

result if result.is_a?(Prism::AssocNode)
end

#: (String association_name) -> void
def handle_association_name(association_name)
result = @client.association_target(
model_name: @nesting.join("::"),
association_name: association_name,
Expand All @@ -164,6 +209,16 @@ def generate_hover(name)
@response_builder.push(content, category: category)
end
end

#: (Prism::Node) -> String?
def extract_string_from_node(node)
case node
when Prism::SymbolNode
node.unescaped
when Prism::StringNode
node.content
end
end
end
end
end
1 change: 1 addition & 0 deletions test/dummy/app/models/country.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# frozen_string_literal: true

class Country < ApplicationRecord
has_one :flag, dependent: :destroy
end
5 changes: 5 additions & 0 deletions test/dummy/app/models/flag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class Flag < ApplicationRecord
belongs_to :country
end
3 changes: 2 additions & 1 deletion test/dummy/app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ class User < ApplicationRecord
validates :first_name, presence: true
has_one :profile
scope :adult, -> { where(age: 18..) }
has_one :location, class_name: "Country"
belongs_to :location, class_name: "Country"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going off of the schema I'm pretty sure this was incorrectly set up, as I don't see any tests that expect this to be invalid.

has_one :country_flag, through: :location, source: :flag

attr_readonly :last_name

Expand Down
9 changes: 9 additions & 0 deletions test/dummy/db/migrate/20250703132109_create_flags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class CreateFlags < ActiveRecord::Migration[8.0]
def change
create_table :flags do |t|
t.references :country, null: false, foreign_key: true

t.timestamps
end
end
end
10 changes: 9 additions & 1 deletion test/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

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

create_table "flags", force: :cascade do |t|
t.integer "country_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["country_id"], name: "index_flags_on_country_id"
end

create_table "memberships", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "organization_id", null: false
Expand Down Expand Up @@ -58,6 +65,7 @@
t.index ["country_id"], name: "index_users_on_country_id"
end

add_foreign_key "flags", "countries"
add_foreign_key "memberships", "organizations"
add_foreign_key "memberships", "users"
add_foreign_key "users", "countries"
Expand Down
Loading
Loading