Skip to content

Commit

Permalink
Completion for Active Record .where queries (#526)
Browse files Browse the repository at this point in the history
* Add completion for AR .where queries using AR model's column names

* Address PR comments

* PR comments addressed and a new test case added
  • Loading branch information
ChallaHalla authored Jan 6, 2025
1 parent 44f8c29 commit 906212c
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 0 deletions.
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

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

0 comments on commit 906212c

Please sign in to comment.