Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Completion for Active Record .where queries #526

Merged
merged 4 commits into from
Jan 6, 2025
Merged
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
13 changes: 13 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
require_relative "code_lens"
require_relative "document_symbol"
require_relative "definition"
require_relative "completion"
require_relative "indexing_enhancement"

module RubyLsp
Expand Down Expand Up @@ -119,6 +120,18 @@ def create_definition_listener(response_builder, uri, node_context, dispatcher)
Definition.new(@rails_runner_client, response_builder, node_context, index, dispatcher)
end

sig do
override.params(
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem],
node_context: NodeContext,
dispatcher: Prism::Dispatcher,
uri: URI::Generic,
).void
end
def create_completion_listener(response_builder, node_context, dispatcher, uri)
Completion.new(@rails_runner_client, response_builder, node_context, dispatcher, uri)
end

sig { params(changes: T::Array[{ uri: String, type: Integer }]).void }
def workspace_did_change_watched_files(changes)
if changes.any? { |c| c[:uri].end_with?("db/schema.rb") || c[:uri].end_with?("structure.sql") }
Expand Down
92 changes: 92 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rails/completion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Rails
class Completion
extend T::Sig
include Requests::Support::Common

sig do
override.params(
client: RunnerClient,
response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::CompletionItem],
node_context: NodeContext,
dispatcher: Prism::Dispatcher,
uri: URI::Generic,
).void
end
def initialize(client, response_builder, node_context, dispatcher, uri)
@response_builder = response_builder
@client = client
@node_context = node_context
dispatcher.register(
self,
:on_call_node_enter,
)
end

sig { params(node: Prism::CallNode).void }
def on_call_node_enter(node)
call_node = @node_context.call_node
return unless call_node

receiver = call_node.receiver
if call_node.name == :where && receiver.is_a?(Prism::ConstantReadNode)
handle_active_record_where_completions(node: node, receiver: receiver)
end
end

private

sig { params(node: Prism::CallNode, receiver: Prism::ConstantReadNode).void }
def handle_active_record_where_completions(node:, receiver:)
resolved_class = @client.model(receiver.name.to_s)
return if resolved_class.nil?

arguments = T.must(@node_context.call_node).arguments&.arguments
indexed_call_node_args = T.let({}, T::Hash[String, Prism::Node])

if arguments
indexed_call_node_args = index_call_node_args(arguments: arguments)
return if indexed_call_node_args.values.any? { |v| v == node }
end
vinistock marked this conversation as resolved.
Show resolved Hide resolved

range = range_from_location(node.location)

resolved_class[:columns].each do |column|
next unless column[0].start_with?(node.name.to_s)
next if indexed_call_node_args.key?(column[0])

@response_builder << Interface::CompletionItem.new(
label: column[0],
filter_text: column[0],
label_details: Interface::CompletionItemLabelDetails.new(
description: "Filter #{receiver.name} records by #{column[0]}",
),
text_edit: Interface::TextEdit.new(range: range, new_text: "#{column[0]}: "),
kind: Constant::CompletionItemKind::FIELD,
)
end
end

sig { params(arguments: T::Array[Prism::Node]).returns(T::Hash[String, Prism::Node]) }
def index_call_node_args(arguments:)
indexed_call_node_args = {}
arguments.each do |argument|
next unless argument.is_a?(Prism::KeywordHashNode)

argument.elements.each do |e|
next unless e.is_a?(Prism::AssocNode)

key = e.key
if key.is_a?(Prism::SymbolNode)
indexed_call_node_args[key.value] = e.value
end
end
end
indexed_call_node_args
end
end
end
end
77 changes: 77 additions & 0 deletions test/ruby_lsp_rails/completion_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# typed: true
# frozen_string_literal: true

require "test_helper"

module RubyLsp
module Rails
class CompletionTest < ActiveSupport::TestCase
test "on_call_node_enter returns when node_context has no call node" do
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 5 })
# typed: false
where
RUBY

assert_equal(0, response.size)
end

test "on_call_node_enter provides no suggestions when .where is called on a non ActiveRecord model" do
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 20 })
# typed: false
FakeClass.where(crea
RUBY

assert_equal(0, response.size)
end

test "on_call_node_enter provides completions when AR model column name is typed partially" do
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 17 })
# typed: false
User.where(first_
RUBY

assert_equal(1, response.size)
assert_equal("first_name", response[0].label)
assert_equal("first_name", response[0].filter_text)
assert_equal(11, response[0].text_edit.range.start.character)
assert_equal(1, response[0].text_edit.range.start.line)
assert_equal(17, response[0].text_edit.range.end.character)
assert_equal(1, response[0].text_edit.range.end.line)
end

test "on_call_node_enter does not provide column name suggestion if column is already a key in the .where call" do
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 37 })
# typed: false
User.where(id:, first_name:, first_na
RUBY

assert_equal(0, response.size)
end

test "on_call_node_enter doesn't provide completions when typing an argument's value within a .where call" do
response = generate_completions_for_source(<<~RUBY, { line: 1, character: 20 })
# typed: false
User.where(id: creat
RUBY
assert_equal(0, response.size)
end

private

def generate_completions_for_source(source, position)
with_server(source) do |server, uri|
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)

server.process_message(
id: 1,
method: "textDocument/completion",
params: { textDocument: { uri: uri }, position: position },
)

result = pop_result(server)
result.response
end
end
end
end
end
Loading