Skip to content

Commit 298dd02

Browse files
committed
Implement method autocompletion in Active Record migrations
1 parent 74b324d commit 298dd02

File tree

7 files changed

+169
-15
lines changed

7 files changed

+169
-15
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ def create_definition_listener(response_builder, uri, node_context, dispatcher)
135135
# @override
136136
#: (ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, NodeContext node_context, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
137137
def create_completion_listener(response_builder, node_context, dispatcher, uri)
138-
Completion.new(@rails_runner_client, response_builder, node_context, dispatcher, uri)
138+
return unless @global_state
139+
140+
Completion.new(@rails_runner_client, response_builder, node_context, @global_state.index, dispatcher, uri)
139141
end
140142

141143
#: (Array[{uri: String, type: Integer}] changes) -> void

lib/ruby_lsp/ruby_lsp_rails/completion.rb

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ class Completion
77
include Requests::Support::Common
88

99
# @override
10-
#: (RunnerClient client, ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, NodeContext node_context, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
11-
def initialize(client, response_builder, node_context, dispatcher, uri)
10+
#: (RunnerClient client, ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem] response_builder, NodeContext node_context, RubyIndexer::Index index, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
11+
def initialize(client, response_builder, node_context, index, dispatcher, uri)
1212
@response_builder = response_builder
1313
@client = client
1414
@node_context = node_context
15+
@index = index
16+
@path = uri.to_standardized_path #: String?
1517
dispatcher.register(
1618
self,
1719
:on_call_node_enter,
@@ -21,11 +23,12 @@ def initialize(client, response_builder, node_context, dispatcher, uri)
2123
#: (Prism::CallNode node) -> void
2224
def on_call_node_enter(node)
2325
call_node = @node_context.call_node
24-
return unless call_node
26+
receiver = call_node&.receiver
2527

26-
receiver = call_node.receiver
27-
if call_node.name == :where && receiver.is_a?(Prism::ConstantReadNode)
28+
if call_node&.name == :where && receiver.is_a?(Prism::ConstantReadNode)
2829
handle_active_record_where_completions(node: node, receiver: receiver)
30+
elsif active_record_migration?
31+
handle_active_record_migration_completions(node: node)
2932
end
3033
end
3134

@@ -62,6 +65,45 @@ def handle_active_record_where_completions(node:, receiver:)
6265
end
6366
end
6467

68+
#: (node: Prism::CallNode) -> void
69+
def handle_active_record_migration_completions(node:)
70+
return if @path.nil?
71+
72+
db_configs = @client.db_configs
73+
return if db_configs.nil?
74+
75+
db_config = db_configs.values.find do |config|
76+
config[:migrations_paths].any? do |path|
77+
File.join(@client.rails_root, path) == File.dirname(@path)
78+
end
79+
end
80+
return if db_config.nil?
81+
82+
range = range_from_location(node.location)
83+
84+
@index.method_completion_candidates(node.message, db_config[:adapter_class]).each do |entry|
85+
next unless entry.public?
86+
87+
entry_name = entry.name
88+
owner_name = entry.owner&.name
89+
90+
label_details = Interface::CompletionItemLabelDetails.new(
91+
description: entry.file_name,
92+
detail: entry.decorated_parameters,
93+
)
94+
@response_builder << Interface::CompletionItem.new(
95+
label: entry_name,
96+
filter_text: entry_name,
97+
label_details: label_details,
98+
text_edit: Interface::TextEdit.new(range: range, new_text: entry_name),
99+
kind: Constant::CompletionItemKind::METHOD,
100+
data: {
101+
owner_name: owner_name,
102+
},
103+
)
104+
end
105+
end
106+
65107
#: (arguments: Array[Prism::Node]) -> Hash[String, Prism::Node]
66108
def index_call_node_args(arguments:)
67109
indexed_call_node_args = {}
@@ -79,6 +121,28 @@ def index_call_node_args(arguments:)
79121
end
80122
indexed_call_node_args
81123
end
124+
125+
# Checks that we're on instance level of a `ActiveRecord::Migration` subclass.
126+
#
127+
#: -> bool
128+
def active_record_migration?
129+
nesting_nodes = @node_context.instance_variable_get(:@nesting_nodes).reverse
130+
class_node = nesting_nodes.find { |node| node.is_a?(Prism::ClassNode) }
131+
return false unless class_node
132+
133+
superclass = class_node.superclass
134+
return false unless superclass.is_a?(Prism::CallNode)
135+
136+
receiver = superclass.receiver
137+
return false unless receiver.is_a?(Prism::ConstantPathNode)
138+
return false unless receiver.slice == "ActiveRecord::Migration"
139+
140+
def_node = nesting_nodes.find { |n| n.is_a?(Prism::DefNode) }
141+
return false if def_node.receiver
142+
143+
true
144+
end
145+
82146
end
83147
end
84148
end

lib/ruby_lsp/ruby_lsp_rails/runner_client.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,17 @@ def register_server_addon(server_addon_path)
133133
nil
134134
end
135135

136+
#: -> Hash[Symbol, untyped]?
137+
def db_configs
138+
make_request("db_configs")
139+
rescue MessageError
140+
log_message(
141+
"Ruby LSP Rails failed to get database configurations",
142+
type: RubyLsp::Constant::MessageType::ERROR,
143+
)
144+
nil
145+
end
146+
136147
#: (String name) -> Hash[Symbol, untyped]?
137148
def model(name)
138149
make_request("model", name: name)

lib/ruby_lsp/ruby_lsp_rails/server.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ def execute(request, params)
307307
with_request_error_handling(request) do
308308
send_result(resolve_database_info_from_model(params.fetch(:name)))
309309
end
310+
when "db_configs"
311+
with_request_error_handling(request) do
312+
send_result(resolve_database_configurations)
313+
end
310314
when "association_target"
311315
with_request_error_handling(request) do
312316
send_result(resolve_association_target(params))
@@ -423,6 +427,18 @@ def resolve_database_info_from_model(model_name)
423427
info
424428
end
425429

430+
#: -> Hash[Symbol | String, untyped]?
431+
def resolve_database_configurations
432+
return unless defined?(ActiveRecord)
433+
434+
ActiveRecord::Base.connection_handler.connection_pools.each_with_object({}) do |pool, hash|
435+
hash[pool.db_config.name] = {
436+
migrations_paths: Array(pool.migrations_paths),
437+
adapter_class: pool.db_config.adapter_class.name,
438+
}
439+
end
440+
end
441+
426442
#: (Hash[Symbol | String, untyped]) -> Hash[Symbol | String, untyped]?
427443
def resolve_association_target(params)
428444
const = ActiveSupport::Inflector.safe_constantize(params[:model_name]) # rubocop:disable Sorbet/ConstantsFromStrings

test/ruby_lsp_rails/completion_test.rb

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,61 @@ class CompletionTest < ActiveSupport::TestCase
5656
assert_equal(0, response.size)
5757
end
5858

59+
test "on_call_node_enter provides completion for migration files" do
60+
source = <<~RUBY
61+
# typed: false
62+
class FooBar < ActiveRecord::Migration[8.0]
63+
def change
64+
create
65+
end
66+
end
67+
RUBY
68+
position = { line: 3, character: 10 }
69+
uri = Kernel.URI("file://#{dummy_root}/db/migrate/123456789_foo_bar.rb")
70+
71+
response = with_ready_server(source, uri) do |server|
72+
index_gem(server.global_state.index, "activerecord")
73+
text_document_completion(server, uri, position)
74+
end
75+
76+
assert_includes response.map(&:label), "create_table"
77+
end
78+
5979
private
6080

61-
def generate_completions_for_source(source, position)
62-
with_server(source) do |server, uri|
81+
def generate_completions_for_source(source, position, uri = Kernel.URI("file:///fake.rb"))
82+
with_ready_server(source, uri) do |server, uri|
83+
text_document_completion(server, uri, position)
84+
end
85+
end
86+
87+
def with_ready_server(source, uri)
88+
with_server(source, uri) do |server|
6389
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
6490

65-
server.process_message(
66-
id: 1,
67-
method: "textDocument/completion",
68-
params: { textDocument: { uri: uri }, position: position },
69-
)
91+
yield server
92+
end
93+
end
94+
95+
def text_document_completion(server, uri, position)
96+
server.process_message(
97+
id: 1,
98+
method: "textDocument/completion",
99+
params: { textDocument: { uri: uri }, position: position },
100+
)
101+
102+
result = pop_result(server)
103+
result.response
104+
end
70105

71-
result = pop_result(server)
72-
result.response
106+
def index_gem(index, gem_name)
107+
spec = Gem::Specification.find_by_name(gem_name)
108+
spec.require_paths.each do |require_path|
109+
load_path_entry = File.join(spec.full_gem_path, require_path)
110+
Dir.glob(File.join(load_path_entry, "**", "*.rb")).map! do |path|
111+
uri = URI::Generic.from_path(path: path, load_path_entry: load_path_entry)
112+
index.index_file(uri)
113+
end
73114
end
74115
end
75116
end

test/ruby_lsp_rails/runner_client_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ class RunnerClientTest < ActiveSupport::TestCase
132132
end
133133
end
134134

135+
test "fetches database configurations" do
136+
assert_equal(
137+
{
138+
primary: {
139+
migrations_paths: ["db/migrate"],
140+
adapter_class: "ActiveRecord::ConnectionAdapters::SQLite3Adapter",
141+
},
142+
},
143+
@client.db_configs,
144+
)
145+
end
146+
135147
test "delegate notification" do
136148
@client.expects(:send_notification).with(
137149
"server_addon/delegate",

test/ruby_lsp_rails/server_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ def <(other)
124124
assert_match %r{test/dummy/app/models/country.rb:3$}, location
125125
end
126126

127+
test "resolve database configurations" do
128+
@server.execute("db_configs", {})
129+
migrations_paths = response[:result][:primary][:migrations_paths]
130+
adapter_class = response[:result][:primary][:adapter_class]
131+
assert_includes migrations_paths, "#{dummy_root}/db/migrate"
132+
assert_equal "ActiveRecord::ConnectionAdapters::SQLite3Adapter", adapter_class
133+
end
134+
127135
test "route location returns the location for a valid route" do
128136
@server.execute("route_location", { name: "user_path" })
129137
location = response[:result][:location]

0 commit comments

Comments
 (0)