-
Notifications
You must be signed in to change notification settings - Fork 183
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
@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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason to use symbols here rather than There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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) | ||
vinistock marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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") | ||
vinistock marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -103,6 +103,7 @@ unaliased | |
unindexed | ||
unparser | ||
unresolve | ||
vcall | ||
Vinicius | ||
vscodemachineid | ||
vsctm | ||
|
There was a problem hiding this comment.
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?