Skip to content

Commit 7db4a9f

Browse files
authored
Add discover tests custom request (#3180)
Add discover tests custom request (#3180) ### Motivation Closes #3171 Add a new custom request to discover tests in a specific document. This request will instantiate all listeners and collect all of the discovered groups and examples as test items for the editor. ### Implementation - Created the new request - Implement a listener dedicated to the test style syntax only (spec will be a separate listener) - Ensured to use ancestor linearization to determine the test framework ### Automated Tests Added tests. Co-authored-by: vinistock <[email protected]>
1 parent 44a38fa commit 7db4a9f

File tree

7 files changed

+552
-0
lines changed

7 files changed

+552
-0
lines changed

lib/ruby_lsp/internal.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
require "fileutils"
2525
require "open3"
2626
require "securerandom"
27+
require "shellwords"
2728

2829
require "ruby-lsp"
2930
require "ruby_lsp/base_server"
@@ -72,6 +73,7 @@
7273
require "ruby_lsp/requests/completion"
7374
require "ruby_lsp/requests/definition"
7475
require "ruby_lsp/requests/diagnostics"
76+
require "ruby_lsp/requests/discover_tests"
7577
require "ruby_lsp/requests/document_highlight"
7678
require "ruby_lsp/requests/document_link"
7779
require "ruby_lsp/requests/document_symbol"

lib/ruby_lsp/listeners/test_style.rb

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Listeners
6+
class TestStyle
7+
extend T::Sig
8+
include Requests::Support::Common
9+
10+
ACCESS_MODIFIERS = [:public, :private, :protected].freeze
11+
DYNAMIC_REFERENCE_MARKER = "<dynamic_reference>"
12+
13+
sig do
14+
params(
15+
response_builder: ResponseBuilders::TestCollection,
16+
global_state: GlobalState,
17+
dispatcher: Prism::Dispatcher,
18+
uri: URI::Generic,
19+
).void
20+
end
21+
def initialize(response_builder, global_state, dispatcher, uri)
22+
@response_builder = response_builder
23+
@global_state = global_state
24+
@uri = uri
25+
@index = T.let(global_state.index, RubyIndexer::Index)
26+
27+
@visibility_stack = T.let([:public], T::Array[Symbol])
28+
@nesting = T.let([], T::Array[String])
29+
30+
dispatcher.register(
31+
self,
32+
:on_class_node_enter,
33+
:on_class_node_leave,
34+
:on_module_node_enter,
35+
:on_module_node_leave,
36+
:on_def_node_enter,
37+
:on_call_node_enter,
38+
:on_call_node_leave,
39+
)
40+
end
41+
42+
sig { params(node: Prism::ClassNode).void }
43+
def on_class_node_enter(node)
44+
@visibility_stack << :public
45+
name = constant_name(node.constant_path)
46+
name ||= name_with_dynamic_reference(node.constant_path)
47+
48+
fully_qualified_name = RubyIndexer::Index.actual_nesting(@nesting, name).join("::")
49+
50+
attached_ancestors = begin
51+
@index.linearized_ancestors_of(fully_qualified_name)
52+
rescue RubyIndexer::Index::NonExistingNamespaceError
53+
# When there are dynamic parts in the constant path, we will not have indexed the namespace. We can still
54+
# provide test functionality if the class inherits directly from Test::Unit::TestCase or Minitest::Test
55+
[node.superclass&.slice].compact
56+
end
57+
58+
if attached_ancestors.include?("Test::Unit::TestCase") ||
59+
non_declarative_minitest?(attached_ancestors, fully_qualified_name)
60+
61+
@response_builder.add(Requests::Support::TestItem.new(
62+
fully_qualified_name,
63+
fully_qualified_name,
64+
@uri,
65+
range_from_node(node),
66+
))
67+
end
68+
69+
@nesting << name
70+
end
71+
72+
sig { params(node: Prism::ModuleNode).void }
73+
def on_module_node_enter(node)
74+
@visibility_stack << :public
75+
76+
name = constant_name(node.constant_path)
77+
name ||= name_with_dynamic_reference(node.constant_path)
78+
79+
@nesting << name
80+
end
81+
82+
sig { params(node: Prism::ModuleNode).void }
83+
def on_module_node_leave(node)
84+
@visibility_stack.pop
85+
@nesting.pop
86+
end
87+
88+
sig { params(node: Prism::ClassNode).void }
89+
def on_class_node_leave(node)
90+
@visibility_stack.pop
91+
@nesting.pop
92+
end
93+
94+
sig { params(node: Prism::DefNode).void }
95+
def on_def_node_enter(node)
96+
return if @visibility_stack.last != :public
97+
98+
name = node.name.to_s
99+
return unless name.start_with?("test_")
100+
101+
current_group_name = RubyIndexer::Index.actual_nesting(@nesting, nil).join("::")
102+
103+
# If we're finding a test method, but for the wrong framework, then the group test item will not have been
104+
# previously pushed and thus we return early and avoid adding items for a framework this listener is not
105+
# interested in
106+
test_item = @response_builder[current_group_name]
107+
return unless test_item
108+
109+
test_item.add(Requests::Support::TestItem.new(
110+
"#{current_group_name}##{name}",
111+
name,
112+
@uri,
113+
range_from_node(node),
114+
))
115+
end
116+
117+
sig { params(node: Prism::CallNode).void }
118+
def on_call_node_enter(node)
119+
name = node.name
120+
return unless ACCESS_MODIFIERS.include?(name)
121+
122+
@visibility_stack << name
123+
end
124+
125+
sig { params(node: Prism::CallNode).void }
126+
def on_call_node_leave(node)
127+
name = node.name
128+
return unless ACCESS_MODIFIERS.include?(name)
129+
return unless node.arguments&.arguments
130+
131+
@visibility_stack.pop
132+
end
133+
134+
private
135+
136+
sig { params(attached_ancestors: T::Array[String], fully_qualified_name: String).returns(T::Boolean) }
137+
def non_declarative_minitest?(attached_ancestors, fully_qualified_name)
138+
return false unless attached_ancestors.include?("Minitest::Test")
139+
140+
# We only support regular Minitest tests. The declarative syntax provided by ActiveSupport is handled by the
141+
# Rails add-on
142+
name_parts = fully_qualified_name.split("::")
143+
singleton_name = "#{name_parts.join("::")}::<Class:#{name_parts.last}>"
144+
!@index.linearized_ancestors_of(singleton_name).include?("ActiveSupport::Testing::Declarative")
145+
rescue RubyIndexer::Index::NonExistingNamespaceError
146+
true
147+
end
148+
149+
sig do
150+
params(
151+
node: T.any(
152+
Prism::ConstantPathNode,
153+
Prism::ConstantReadNode,
154+
Prism::ConstantPathTargetNode,
155+
Prism::CallNode,
156+
Prism::MissingNode,
157+
),
158+
).returns(String)
159+
end
160+
def name_with_dynamic_reference(node)
161+
slice = node.slice
162+
slice.gsub(/((?<=::)|^)[a-z]\w*/, DYNAMIC_REFERENCE_MARKER)
163+
end
164+
end
165+
end
166+
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "ruby_lsp/listeners/test_style"
5+
6+
module RubyLsp
7+
module Requests
8+
# This is a custom request to ask the server to parse a test file and discover all available examples in it. Add-ons
9+
# can augment the behavior through listeners, allowing them to handle discovery for different frameworks
10+
class DiscoverTests < Request
11+
extend T::Sig
12+
include Support::Common
13+
14+
sig { params(global_state: GlobalState, document: RubyDocument, dispatcher: Prism::Dispatcher).void }
15+
def initialize(global_state, document, dispatcher)
16+
super()
17+
@global_state = global_state
18+
@document = document
19+
@dispatcher = dispatcher
20+
@response_builder = T.let(ResponseBuilders::TestCollection.new, ResponseBuilders::TestCollection)
21+
@index = T.let(global_state.index, RubyIndexer::Index)
22+
end
23+
24+
sig { override.returns(T::Array[Support::TestItem]) }
25+
def perform
26+
uri = @document.uri
27+
28+
# We normally only index test files once they are opened in the editor to save memory and avoid doing
29+
# unnecessary work. If the file is already opened and we already indexed it, then we can just discover the tests
30+
# straight away.
31+
#
32+
# However, if the user navigates to a specific test file from the explorer with nothing opened in the UI, then
33+
# we will not have indexed the test file yet and trying to linearize the ancestor of the class will fail. In
34+
# this case, we have to instantiate the indexer listener first, so that we insert classes, modules and methods
35+
# in the index first and then discover the tests, all in the same traversal.
36+
if @index.entries_for(uri.to_s)
37+
Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri)
38+
@dispatcher.visit(@document.parse_result.value)
39+
else
40+
@global_state.synchronize do
41+
RubyIndexer::DeclarationListener.new(
42+
@index,
43+
@dispatcher,
44+
@document.parse_result,
45+
uri,
46+
collect_comments: true,
47+
)
48+
49+
Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri)
50+
51+
# Dispatch the events both for indexing the test file and discovering the tests. The order here is
52+
# important because we need the index to be aware of the existing classes/modules/methods before the test
53+
# listeners can do their work
54+
@dispatcher.visit(@document.parse_result.value)
55+
end
56+
end
57+
58+
@response_builder.response
59+
end
60+
end
61+
end
62+
end

lib/ruby_lsp/requests/support/common.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ def markdown_from_index_entries(title, entries, max_entries = nil, extra_links:
141141
Prism::ConstantPathNode,
142142
Prism::ConstantReadNode,
143143
Prism::ConstantPathTargetNode,
144+
Prism::CallNode,
145+
Prism::MissingNode,
144146
),
145147
).returns(T.nilable(String))
146148
end

lib/ruby_lsp/server.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ def process_message(message)
110110
compose_bundle(message)
111111
when "rubyLsp/diagnoseState"
112112
diagnose_state(message)
113+
when "rubyLsp/discoverTests"
114+
discover_tests(message)
113115
when "$/cancelRequest"
114116
@global_state.synchronize { @cancelled_requests << message[:params][:id] }
115117
when nil
@@ -1389,5 +1391,28 @@ def diagnose_state(message)
13891391
),
13901392
)
13911393
end
1394+
1395+
# Discovers all available test groups and examples in a given file taking into consideration the merged response of
1396+
# all add-ons
1397+
sig { params(message: T::Hash[Symbol, T.untyped]).void }
1398+
def discover_tests(message)
1399+
document = @store.get(message.dig(:params, :textDocument, :uri))
1400+
1401+
unless document.is_a?(RubyDocument)
1402+
send_empty_response(message[:id])
1403+
return
1404+
end
1405+
1406+
cached_response = document.cache_get("rubyLsp/discoverTests")
1407+
if cached_response != Document::EMPTY_CACHE
1408+
send_message(Result.new(id: message[:id], response: cached_response.map(&:to_hash)))
1409+
return
1410+
end
1411+
1412+
items = Requests::DiscoverTests.new(@global_state, document, Prism::Dispatcher.new).perform
1413+
document.cache_set("rubyLsp/discoverTests", items)
1414+
1415+
send_message(Result.new(id: message[:id], response: items.map(&:to_hash)))
1416+
end
13921417
end
13931418
end

project-words

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ unaliased
103103
unindexed
104104
unparser
105105
unresolve
106+
vcall
106107
Vinicius
107108
vscodemachineid
108109
vsctm

0 commit comments

Comments
 (0)