Skip to content

Commit

Permalink
Add discover tests custom request
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Feb 12, 2025
1 parent 675fbff commit 8f68f57
Show file tree
Hide file tree
Showing 7 changed files with 541 additions and 0 deletions.
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
157 changes: 157 additions & 0 deletions lib/ruby_lsp/listeners/test_style.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# 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::TestItems,
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
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 << 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

current_group_name = RubyIndexer::Index.actual_nesting(@nesting, nil).join("::")
test_item = @response_builder[current_group_name]
return unless test_item

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

test_item << 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}>"
!@index.linearized_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
60 changes: 60 additions & 0 deletions lib/ruby_lsp/requests/discover_tests.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 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::TestItems.new, ResponseBuilders::TestItems)
@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
@index.handle_change(uri) do |index|
RubyIndexer::DeclarationListener.new(
index,
@dispatcher,
@document.parse_result,
uri,
collect_comments: true,
)

Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri)
@dispatcher.dispatch(@document.parse_result.value)
end
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 @@ -108,6 +108,8 @@ def process_message(message)
)
when "rubyLsp/composeBundle"
compose_bundle(message)
when "rubyLsp/discoverTests"
discover_tests(message)
when "$/cancelRequest"
@global_state.synchronize { @cancelled_requests << message[:params][:id] }
when nil
Expand Down Expand Up @@ -1368,5 +1370,28 @@ def compose_bundle(message)
end
end
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

0 comments on commit 8f68f57

Please sign in to comment.