From be153880b27bd67860d461564ced87976e7a8cee Mon Sep 17 00:00:00 2001 From: Alex Rocha Date: Tue, 18 Feb 2025 10:52:03 -0800 Subject: [PATCH] Add listener for minitest spec style syntax --- lib/ruby_lsp/listeners/spec_style.rb | 197 ++++++++++++++++++ lib/ruby_lsp/requests/discover_tests.rb | 3 + .../minitest_spec_describe_with_classname.rb | 9 + test/fixtures/minitest_spec_dynamic_name.rb | 9 + test/fixtures/minitest_spec_nested.rb | 17 ++ test/fixtures/minitest_spec_simple.rb | 14 ++ test/fixtures/minitest_spec_test_style.rb | 7 + test/requests/discover_tests_test.rb | 106 ++++++++++ 8 files changed, 362 insertions(+) create mode 100644 lib/ruby_lsp/listeners/spec_style.rb create mode 100644 test/fixtures/minitest_spec_describe_with_classname.rb create mode 100644 test/fixtures/minitest_spec_dynamic_name.rb create mode 100644 test/fixtures/minitest_spec_nested.rb create mode 100644 test/fixtures/minitest_spec_simple.rb create mode 100644 test/fixtures/minitest_spec_test_style.rb diff --git a/lib/ruby_lsp/listeners/spec_style.rb b/lib/ruby_lsp/listeners/spec_style.rb new file mode 100644 index 0000000000..06bb3b3618 --- /dev/null +++ b/lib/ruby_lsp/listeners/spec_style.rb @@ -0,0 +1,197 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class SpecStyle + extend T::Sig + include Requests::Support::Common + + DYNAMIC_REFERENCE_MARKER = "" + + #: (response_builder: ResponseBuilders::TestCollection, global_state: GlobalState, dispatcher: Prism::Dispatcher, uri: URI::Generic) -> void + def initialize(response_builder, global_state, dispatcher, uri) + @response_builder = response_builder + @uri = uri + @index = T.let(global_state.index, RubyIndexer::Index) + @visibility_stack = T.let([:public], T::Array[Symbol]) + @nesting = T.let([], T::Array[String]) + @describe_block_nesting = T.let([], T::Array[String]) + @spec_class_stack = T.let([], T::Array[T::Boolean]) + + dispatcher.register( + self, + :on_class_node_enter, + :on_class_node_leave, + :on_module_node_enter, + :on_module_node_leave, + :on_call_node_enter, # e.g. `describe` or `it` + :on_call_node_leave, + ) + end + + #: (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 + + is_spec = attached_ancestors.include?("Minitest::Spec") + @spec_class_stack.push(is_spec) + + @nesting << name + end + + #: (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 + + #: (node: Prism::ModuleNode) -> void + def on_module_node_leave(node) + @visibility_stack.pop + @nesting.pop + end + + #: (node: Prism::ClassNode) -> void + def on_class_node_leave(node) + @visibility_stack.pop + @nesting.pop + @spec_class_stack.pop + end + + #: (node: Prism::CallNode) -> void + def on_call_node_enter(node) + case node.name + when :describe + handle_describe(node) + when :it, :specify + handle_example(node) + end + end + + #: (node: Prism::CallNode) -> void + def on_call_node_leave(node) + return unless node.name == :describe && !node.receiver + + @describe_block_nesting.pop + end + + private + + #: (node: Prism::CallNode) -> void + def handle_describe(node) + return if node.block.nil? + + description = extract_description(node) + return unless description + + return unless in_spec_context? + + if @nesting.empty? && @describe_block_nesting.empty? + test_item = Requests::Support::TestItem.new( + description, + description, + @uri, + range_from_node(node), + ) + @response_builder.add(test_item) + else + add_to_parent_test_group(description, node) + end + + @describe_block_nesting << description + end + + #: (node: Prism::CallNode) -> void + def handle_example(node) + return unless in_spec_context? + + return if @describe_block_nesting.empty? && @nesting.empty? + + description = extract_description(node) + return unless description + + add_to_parent_test_group(description, node) + end + + #: (description: String, node: Prism::CallNode) -> void + def add_to_parent_test_group(description, node) + parent_test_group = find_parent_test_group + return unless parent_test_group + + test_item = Requests::Support::TestItem.new( + description, + description, + @uri, + range_from_node(node), + ) + parent_test_group.add(test_item) + end + + #: -> Requests::Support::TestItem? + def find_parent_test_group + root_group_name, nested_describe_groups = if @nesting.empty? + [@describe_block_nesting.first, @describe_block_nesting[1..]] + else + [RubyIndexer::Index.actual_nesting(@nesting, nil).join("::"), @describe_block_nesting] + end + return unless root_group_name + + test_group = T.let(@response_builder[root_group_name], T.nilable(Requests::Support::TestItem)) + return unless test_group + + return test_group unless nested_describe_groups + + nested_describe_groups.each do |description| + test_group = test_group[description] + end + + test_group + end + + #: (node: Prism::CallNode) -> String? + def extract_description(node) + first_argument = node.arguments&.arguments&.first + return unless first_argument + + case first_argument + when Prism::StringNode + first_argument.content + when Prism::ConstantReadNode, Prism::ConstantPathNode + constant_name(first_argument) + else + first_argument.slice + end + end + + #: -> bool + def in_spec_context? + return true if @nesting.empty? + + T.must(@spec_class_stack.last) + end + + #: (node: Prism::ConstantPathNode | Prism::ConstantReadNode | Prism::ConstantPathTargetNode | Prism::CallNode | Prism::MissingNode) -> String + def name_with_dynamic_reference(node) + slice = node.slice + slice.gsub(/((?<=::)|^)[a-z]\w*/, DYNAMIC_REFERENCE_MARKER) + end + end + end +end diff --git a/lib/ruby_lsp/requests/discover_tests.rb b/lib/ruby_lsp/requests/discover_tests.rb index 48ad7b283e..698cbb00bd 100644 --- a/lib/ruby_lsp/requests/discover_tests.rb +++ b/lib/ruby_lsp/requests/discover_tests.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "ruby_lsp/listeners/test_style" +require "ruby_lsp/listeners/spec_style" module RubyLsp module Requests @@ -36,6 +37,7 @@ def perform # 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) + Listeners::SpecStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) @dispatcher.visit(@document.parse_result.value) else @global_state.synchronize do @@ -48,6 +50,7 @@ def perform ) Listeners::TestStyle.new(@response_builder, @global_state, @dispatcher, @document.uri) + Listeners::SpecStyle.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 diff --git a/test/fixtures/minitest_spec_describe_with_classname.rb b/test/fixtures/minitest_spec_describe_with_classname.rb new file mode 100644 index 0000000000..75128619db --- /dev/null +++ b/test/fixtures/minitest_spec_describe_with_classname.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BogusSpec < Minitest::Spec + describe AnotherSpec do + it "works fine" do + assert true + end + end +end diff --git a/test/fixtures/minitest_spec_dynamic_name.rb b/test/fixtures/minitest_spec_dynamic_name.rb new file mode 100644 index 0000000000..f6b7ea4b50 --- /dev/null +++ b/test/fixtures/minitest_spec_dynamic_name.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class BogusSpec < Minitest::Spec + describe "First Spec" do + it dynamic_name do + assert true + end + end +end diff --git a/test/fixtures/minitest_spec_nested.rb b/test/fixtures/minitest_spec_nested.rb new file mode 100644 index 0000000000..e0e6bb4f80 --- /dev/null +++ b/test/fixtures/minitest_spec_nested.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class BogusSpec < Minitest::Spec + describe "First Spec" do + it "test one" do + assert true + end + + it "test two" do + assert true + end + + specify "test three" do + assert true + end + end +end diff --git a/test/fixtures/minitest_spec_simple.rb b/test/fixtures/minitest_spec_simple.rb new file mode 100644 index 0000000000..17dfa0d3e7 --- /dev/null +++ b/test/fixtures/minitest_spec_simple.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class BogusSpec < Minitest::Spec + it "test one" do + assert true + end + + specify "test two" do + assert true + end + + describe do + end +end diff --git a/test/fixtures/minitest_spec_test_style.rb b/test/fixtures/minitest_spec_test_style.rb new file mode 100644 index 0000000000..4ace762a04 --- /dev/null +++ b/test/fixtures/minitest_spec_test_style.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class BogusSpec < Minitest::Spec + def test_style + assert(true) + end +end diff --git a/test/requests/discover_tests_test.rb b/test/requests/discover_tests_test.rb index 88b729d4ae..d39b71ae81 100644 --- a/test/requests/discover_tests_test.rb +++ b/test/requests/discover_tests_test.rb @@ -215,6 +215,93 @@ def test_something; end end end + def test_discovers_top_level_specs + source = File.read("test/fixtures/minitest_spec_simple.rb") + + with_minitest_spec_configured(source) do |items| + assert_equal(["BogusSpec"], items.map { |i| i[:label] }) + end + end + + def test_discovers_nested_specs + source = File.read("test/fixtures/minitest_spec_nested.rb") + + with_minitest_spec_configured(source) do |items| + top_level_specs = items[0][:children] + assert_equal( + ["First Spec"], + top_level_specs.map { |i| i[:label] }, + ) + + nested_specs = top_level_specs[0][:children] + assert_equal( + ["test one", "test two", "test three"], + nested_specs.map { |i| i[:label] }, + ) + end + end + + def test_discovers_specs_without_class + source = File.read("test/fixtures/minitest_spec_tests.rb") + + with_minitest_spec_configured(source) do |items| + top_level_specs = items + assert_equal( + ["Foo", "Foo::Bar", "Baz"], + top_level_specs.map { |i| i[:label] }, + ) + + nested_specs = top_level_specs[0][:children] + assert_equal( + ["it_level_one", "nested", "it_level_one_again"], + nested_specs.map { |i| i[:label] }, + ) + end + end + + def test_discovers_dynamic_spec_names + source = File.read("test/fixtures/minitest_spec_dynamic_name.rb") + + with_minitest_spec_configured(source) do |items| + nested_specs = items[0][:children][0][:children] + assert_equal( + ["dynamic_name"], + nested_specs.map { |i| i[:label] }, + ) + end + end + + def test_handles_empty_specs + source = File.read("test/fixtures/minitest_spec_simple.rb") + + with_minitest_spec_configured(source) do |items| + nested_specs = items[0][:children][0][:children] + assert_empty(nested_specs) + end + end + + def test_handles_mixed_testing_styles_in_single_file + source = <<~RUBY + class FooSpec < Minitest::Spec + it "does something" do + end + + def test_also_valid; end + end + RUBY + + with_minitest_spec_configured(source) do |items| + assert_equal(["FooSpec"], items.map { |i| i[:label] }) + assert_equal( + [ + "does something", + "test_also_valid", + ], + items[0][:children].map { |i| i[:label] }, + ) + end + end + private def with_minitest_test(source, &block) @@ -284,6 +371,25 @@ class TestCase < Minitest::Test end end + def with_minitest_spec_configured(source, &block) + with_server(source) do |server, uri| + server.global_state.index.index_single(uri, <<~RUBY) + module Minitest + class Test; end + class Spec < Test; end + end + RUBY + + server.process_message(id: 1, method: "rubyLsp/discoverTests", params: { + textDocument: { uri: uri }, + }) + + items = get_response(server) + + yield items + end + end + def get_response(server) result = server.pop_response