Skip to content

Support Ruby LSP's full test discovery feature #62

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

Merged
merged 8 commits into from
May 10, 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
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
runs-on: ubuntu-latest
name: Ruby ${{ matrix.ruby }}
strategy:
fail-fast: false
matrix:
ruby:
- "3.4"
Expand Down
1 change: 1 addition & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
--format documentation
--color
--require spec_helper
--exclude-pattern **/fixtures/*
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Sorbet/StrictSigil:
- "lib/**/*.rb"
Exclude:
- "**/*.rake"
- "lib/ruby_lsp/ruby_lsp_rspec/rspec_formatter.rb"
- "spec/**/*.rb"

Style/StderrPuts:
Expand Down
22 changes: 11 additions & 11 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ PATH
remote: .
specs:
ruby-lsp-rspec (0.1.22)
ruby-lsp (~> 0.23.0)
ruby-lsp (~> 0.23.17)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -43,7 +43,7 @@ GEM
prism (~> 1.0)
rbs (>= 3.4.4)
sorbet-runtime (>= 0.5.9204)
rbs (3.9.2)
rbs (3.9.3)
logger
rdoc (6.13.1)
psych (>= 4.0.0)
Expand Down Expand Up @@ -83,20 +83,20 @@ GEM
rubocop (~> 1.62)
rubocop-sorbet (0.10.0)
rubocop (>= 1)
ruby-lsp (0.23.15)
ruby-lsp (0.23.17)
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-progressbar (1.13.0)
sorbet (0.5.12043)
sorbet-static (= 0.5.12043)
sorbet-runtime (0.5.12043)
sorbet-static (0.5.12043-universal-darwin)
sorbet-static (0.5.12043-x86_64-linux)
sorbet-static-and-runtime (0.5.12043)
sorbet (= 0.5.12043)
sorbet-runtime (= 0.5.12043)
sorbet (0.5.12048)
sorbet-static (= 0.5.12048)
sorbet-runtime (0.5.12048)
sorbet-static (0.5.12048-universal-darwin)
sorbet-static (0.5.12048-x86_64-linux)
sorbet-static-and-runtime (0.5.12048)
sorbet (= 0.5.12048)
sorbet-runtime (= 0.5.12048)
spoom (1.6.1)
erubi (>= 1.10.0)
prism (>= 0.28.0)
Expand Down
83 changes: 75 additions & 8 deletions lib/ruby_lsp/ruby_lsp_rspec/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
require_relative "document_symbol"
require_relative "definition"
require_relative "indexing_enhancement"
require_relative "test_discovery"
require_relative "spec_style_patch"

module RubyLsp
module RSpec
class Addon < ::RubyLsp::Addon
extend T::Sig

FORMATTER_PATH = T.let(File.expand_path("rspec_formatter.rb", __dir__), String)
FORMATTER_NAME = T.let("RubyLsp::RSpec::RSpecFormatter", String)

sig { returns(T::Boolean) }
attr_reader :debug

Expand All @@ -30,12 +35,18 @@ def activate(global_state, message_queue)

settings = global_state.settings_for_addon(name)
@rspec_command = rspec_command(settings)
@workspace_path = T.let(global_state.workspace_path, T.nilable(String))
@debug = settings&.dig(:debug) || false
end

sig { override.void }
def deactivate; end

sig { override.returns(String) }
def name
"ruby-lsp-rspec"
end

sig { override.returns(String) }
def version
VERSION
Expand All @@ -55,6 +66,68 @@ def create_code_lens_listener(response_builder, uri, dispatcher)
CodeLens.new(response_builder, uri, dispatcher, T.must(@rspec_command), debug: debug)
end

# Creates a new Discover Tests listener. This method is invoked on every DiscoverTests request
sig do
override.params(
response_builder: ResponseBuilders::TestCollection,
dispatcher: Prism::Dispatcher,
uri: URI::Generic,
).void
end
def create_discover_tests_listener(response_builder, dispatcher, uri)
return unless uri.to_standardized_path&.end_with?("_spec.rb")

TestDiscovery.new(response_builder, dispatcher, uri, T.must(@workspace_path))
end

# Resolves the minimal set of commands required to execute the requested tests
sig do
override.params(
items: T::Array[T::Hash[Symbol, T.untyped]],
).returns(T::Array[String])
end
def resolve_test_commands(items)
commands = []
queue = items.dup

full_files = []

until queue.empty?
item = T.must(queue.shift)
tags = Set.new(item[:tags])
next unless tags.include?("framework:rspec")

children = item[:children]
uri = URI(item[:uri])
path = uri.full_path
next unless path

if tags.include?("test_dir")
if children.empty?
full_files.concat(Dir.glob(
"#{path}/**/*_spec.rb",
File::Constants::FNM_EXTGLOB | File::Constants::FNM_PATHNAME,
))
end
elsif tags.include?("test_file")
full_files << path if children.empty?
elsif tags.include?("test_group")
start_line = item.dig(:range, :start, :line)
commands << "#{@rspec_command} -r #{FORMATTER_PATH} -f #{FORMATTER_NAME} #{path}:#{start_line + 1}"
else
full_files << "#{path}:#{item.dig(:range, :start, :line) + 1}"
end

queue.concat(children)
end

unless full_files.empty?
commands << "#{@rspec_command} -r #{FORMATTER_PATH} -f #{FORMATTER_NAME} #{full_files.join(" ")}"
end

commands
end

sig do
override.params(
response_builder: ResponseBuilders::DocumentSymbol,
Expand All @@ -67,10 +140,7 @@ def create_document_symbol_listener(response_builder, dispatcher)

sig do
override.params(
response_builder: ResponseBuilders::CollectionResponseBuilder[T.any(
Interface::Location,
Interface::LocationLink,
)],
response_builder: ResponseBuilders::CollectionResponseBuilder[T.any(Interface::Location, Interface::LocationLink)],
uri: URI::Generic,
node_context: NodeContext,
dispatcher: Prism::Dispatcher,
Expand All @@ -82,10 +152,7 @@ def create_definition_listener(response_builder, uri, node_context, dispatcher)
Definition.new(response_builder, uri, node_context, T.must(@index), dispatcher)
end

sig { override.returns(String) }
def name
"Ruby LSP RSpec"
end
private

sig { params(settings: T.nilable(T::Hash[Symbol, T.untyped])).returns(String) }
def rspec_command(settings)
Expand Down
65 changes: 65 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rspec/rspec_formatter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# typed: true
# frozen_string_literal: true

require "rspec/core/formatters"
require "ruby_lsp/test_reporters/lsp_reporter"

module RubyLsp
module RSpec
class RSpecFormatter
::RSpec::Core::Formatters.register(
self,
:example_passed,
:example_pending,
:example_failed,
:example_started,
:stop,
)

def initialize(output)
@output = output
end

def example_started(notification)
example = notification.example
uri = uri_for(example)
id = generate_id(example)
RubyLsp::LspReporter.instance.start_test(id: id, uri: uri)
end

def example_passed(notification)
example = notification.example
uri = uri_for(example)
id = generate_id(example)
RubyLsp::LspReporter.instance.record_pass(id: id, uri: uri)
end

def example_failed(notification)
example = notification.example
uri = uri_for(example)
id = generate_id(example)
RubyLsp::LspReporter.instance.record_fail(id: id, message: notification.exception.message, uri: uri)
end

def example_pending(notification)
example = notification.example
uri = uri_for(example)
id = generate_id(example)
RubyLsp::LspReporter.instance.record_skip(id: id, uri: uri)
end

def stop(notification)
RubyLsp::LspReporter.instance.shutdown
end

def uri_for(example)
absolute_path = File.expand_path(example.file_path)
URI::Generic.from_path(path: absolute_path)
end

def generate_id(example)
[example, *example.example_group.parent_groups].reverse.map(&:location).join("::")
end
end
end
end
16 changes: 16 additions & 0 deletions lib/ruby_lsp/ruby_lsp_rspec/spec_style_patch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# typed: strict
# frozen_string_literal: true

module RubyLsp
module Listeners
# Patching this listener so it doesn't generate test items for RSpec tests
class SpecStyle
extend T::Sig

sig { params(response_builder: ResponseBuilders::TestCollection, global_state: GlobalState, dispatcher: Prism::Dispatcher, uri: URI::Generic).void }
def initialize(response_builder, global_state, dispatcher, uri)
super
end
end
end
end
Loading