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

Add discover tests custom request #3180

Merged
merged 1 commit into from
Feb 13, 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
2 changes: 2 additions & 0 deletions lib/ruby_lsp/internal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
require "fileutils"
require "open3"
require "securerandom"
require "shellwords"

require "ruby-lsp"
require "ruby_lsp/base_server"
Expand Down Expand Up @@ -72,6 +73,7 @@
require "ruby_lsp/requests/completion"
require "ruby_lsp/requests/definition"
require "ruby_lsp/requests/diagnostics"
require "ruby_lsp/requests/discover_tests"
require "ruby_lsp/requests/document_highlight"
require "ruby_lsp/requests/document_link"
require "ruby_lsp/requests/document_symbol"
Expand Down
166 changes: 166 additions & 0 deletions lib/ruby_lsp/listeners/test_style.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Listeners
class TestStyle
extend T::Sig
include Requests::Support::Common

ACCESS_MODIFIERS = [:public, :private, :protected].freeze
DYNAMIC_REFERENCE_MARKER = "<dynamic_reference>"

sig do
params(
response_builder: ResponseBuilders::TestCollection,
global_state: GlobalState,
dispatcher: Prism::Dispatcher,
uri: URI::Generic,
).void
end
def initialize(response_builder, global_state, dispatcher, uri)
@response_builder = response_builder
@global_state = global_state
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like ivar is unused?

@uri = uri
@index = T.let(global_state.index, RubyIndexer::Index)

@visibility_stack = T.let([:public], T::Array[Symbol])
@nesting = T.let([], T::Array[String])

dispatcher.register(
self,
:on_class_node_enter,
:on_class_node_leave,
:on_module_node_enter,
:on_module_node_leave,
:on_def_node_enter,
:on_call_node_enter,
:on_call_node_leave,
)
end

sig { params(node: Prism::ClassNode).void }
def on_class_node_enter(node)
@visibility_stack << :public
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason to use symbols here rather than VisibilityScope?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's what we were using before in the original implementation in code lens. I think the bigger refactor here is realizing that many listeners need to keep track of scopes, so maybe we can implement our own Prism::Dispatcher subclass that automatically tracks this, so that we don't need to duplicate the logic in multiple places.

name = constant_name(node.constant_path)
name ||= name_with_dynamic_reference(node.constant_path)

fully_qualified_name = RubyIndexer::Index.actual_nesting(@nesting, name).join("::")

attached_ancestors = begin
@index.linearized_ancestors_of(fully_qualified_name)
rescue RubyIndexer::Index::NonExistingNamespaceError
# When there are dynamic parts in the constant path, we will not have indexed the namespace. We can still
# provide test functionality if the class inherits directly from Test::Unit::TestCase or Minitest::Test
[node.superclass&.slice].compact
end

if attached_ancestors.include?("Test::Unit::TestCase") ||
non_declarative_minitest?(attached_ancestors, fully_qualified_name)

@response_builder.add(Requests::Support::TestItem.new(
fully_qualified_name,
fully_qualified_name,
@uri,
range_from_node(node),
))
end

@nesting << name
end

sig { params(node: Prism::ModuleNode).void }
def on_module_node_enter(node)
@visibility_stack << :public

name = constant_name(node.constant_path)
name ||= name_with_dynamic_reference(node.constant_path)

@nesting << name
end

sig { params(node: Prism::ModuleNode).void }
def on_module_node_leave(node)
@visibility_stack.pop
@nesting.pop
end

sig { params(node: Prism::ClassNode).void }
def on_class_node_leave(node)
@visibility_stack.pop
@nesting.pop
end

sig { params(node: Prism::DefNode).void }
def on_def_node_enter(node)
return if @visibility_stack.last != :public

name = node.name.to_s
return unless name.start_with?("test_")

current_group_name = RubyIndexer::Index.actual_nesting(@nesting, nil).join("::")

# If we're finding a test method, but for the wrong framework, then the group test item will not have been
# previously pushed and thus we return early and avoid adding items for a framework this listener is not
# interested in
test_item = @response_builder[current_group_name]
return unless test_item

test_item.add(Requests::Support::TestItem.new(
"#{current_group_name}##{name}",
name,
@uri,
range_from_node(node),
))
end

sig { params(node: Prism::CallNode).void }
def on_call_node_enter(node)
name = node.name
return unless ACCESS_MODIFIERS.include?(name)

@visibility_stack << name
end

sig { params(node: Prism::CallNode).void }
def on_call_node_leave(node)
name = node.name
return unless ACCESS_MODIFIERS.include?(name)
return unless node.arguments&.arguments

@visibility_stack.pop
end

private

sig { params(attached_ancestors: T::Array[String], fully_qualified_name: String).returns(T::Boolean) }
def non_declarative_minitest?(attached_ancestors, fully_qualified_name)
return false unless attached_ancestors.include?("Minitest::Test")

# We only support regular Minitest tests. The declarative syntax provided by ActiveSupport is handled by the
# Rails add-on
name_parts = fully_qualified_name.split("::")
singleton_name = "#{name_parts.join("::")}::<Class:#{name_parts.last}>"
[email protected]_ancestors_of(singleton_name).include?("ActiveSupport::Testing::Declarative")
rescue RubyIndexer::Index::NonExistingNamespaceError
true
end

sig do
params(
node: T.any(
Prism::ConstantPathNode,
Prism::ConstantReadNode,
Prism::ConstantPathTargetNode,
Prism::CallNode,
Prism::MissingNode,
),
).returns(String)
end
def name_with_dynamic_reference(node)
slice = node.slice
slice.gsub(/((?<=::)|^)[a-z]\w*/, DYNAMIC_REFERENCE_MARKER)
end
end
end
end
62 changes: 62 additions & 0 deletions lib/ruby_lsp/requests/discover_tests.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# typed: strict
# frozen_string_literal: true

require "ruby_lsp/listeners/test_style"

module RubyLsp
module Requests
# This is a custom request to ask the server to parse a test file and discover all available examples in it. Add-ons
# can augment the behavior through listeners, allowing them to handle discovery for different frameworks
class DiscoverTests < Request
extend T::Sig
include Support::Common

sig { params(global_state: GlobalState, document: RubyDocument, dispatcher: Prism::Dispatcher).void }
def initialize(global_state, document, dispatcher)
super()
@global_state = global_state
@document = document
@dispatcher = dispatcher
@response_builder = T.let(ResponseBuilders::TestCollection.new, ResponseBuilders::TestCollection)
@index = T.let(global_state.index, RubyIndexer::Index)
end

sig { override.returns(T::Array[Support::TestItem]) }
def perform
uri = @document.uri

# We normally only index test files once they are opened in the editor to save memory and avoid doing
# unnecessary work. If the file is already opened and we already indexed it, then we can just discover the tests
# straight away.
#
# However, if the user navigates to a specific test file from the explorer with nothing opened in the UI, then
# we will not have indexed the test file yet and trying to linearize the ancestor of the class will fail. In
# this case, we have to instantiate the indexer listener first, so that we insert classes, modules and methods
# in the index first and then discover the tests, all in the same traversal.
if @index.entries_for(uri.to_s)
Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri)
@dispatcher.visit(@document.parse_result.value)
else
@global_state.synchronize do
RubyIndexer::DeclarationListener.new(
@index,
@dispatcher,
@document.parse_result,
uri,
collect_comments: true,
)

Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri)

# Dispatch the events both for indexing the test file and discovering the tests. The order here is
# important because we need the index to be aware of the existing classes/modules/methods before the test
# listeners can do their work
@dispatcher.visit(@document.parse_result.value)
end
end

@response_builder.response
end
end
end
end
2 changes: 2 additions & 0 deletions lib/ruby_lsp/requests/support/common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ def markdown_from_index_entries(title, entries, max_entries = nil, extra_links:
Prism::ConstantPathNode,
Prism::ConstantReadNode,
Prism::ConstantPathTargetNode,
Prism::CallNode,
Prism::MissingNode,
),
).returns(T.nilable(String))
end
Expand Down
25 changes: 25 additions & 0 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def process_message(message)
compose_bundle(message)
when "rubyLsp/diagnoseState"
diagnose_state(message)
when "rubyLsp/discoverTests"
discover_tests(message)
when "$/cancelRequest"
@global_state.synchronize { @cancelled_requests << message[:params][:id] }
when nil
Expand Down Expand Up @@ -1389,5 +1391,28 @@ def diagnose_state(message)
),
)
end

# Discovers all available test groups and examples in a given file taking into consideration the merged response of
# all add-ons
sig { params(message: T::Hash[Symbol, T.untyped]).void }
def discover_tests(message)
document = @store.get(message.dig(:params, :textDocument, :uri))

unless document.is_a?(RubyDocument)
send_empty_response(message[:id])
return
end

cached_response = document.cache_get("rubyLsp/discoverTests")
if cached_response != Document::EMPTY_CACHE
send_message(Result.new(id: message[:id], response: cached_response.map(&:to_hash)))
return
end

items = Requests::DiscoverTests.new(@global_state, document, Prism::Dispatcher.new).perform
document.cache_set("rubyLsp/discoverTests", items)

send_message(Result.new(id: message[:id], response: items.map(&:to_hash)))
end
end
end
1 change: 1 addition & 0 deletions project-words
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ unaliased
unindexed
unparser
unresolve
vcall
Vinicius
vscodemachineid
vsctm
Expand Down
Loading
Loading