Skip to content

Commit b083e0c

Browse files
authored
Support Ruby LSP's full test discovery feature (#62)
* WIP * Implement RSpec command resolution * Support streaming RSpec results * Generate ids purely based on locations This makes sure that we can always match the ids even for anonymous examples. * Add tests for the rspec formatter * Don't fail fast on CI * Allow RSpecFormatter to not have typed: strict * Requires ruby-lsp 0.23.17 For Shopify/ruby-lsp#3452
1 parent cc279ea commit b083e0c

File tree

16 files changed

+1339
-458
lines changed

16 files changed

+1339
-458
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313
name: Ruby ${{ matrix.ruby }}
1414
strategy:
15+
fail-fast: false
1516
matrix:
1617
ruby:
1718
- "3.4"

.rspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
--format documentation
22
--color
33
--require spec_helper
4+
--exclude-pattern **/fixtures/*

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Sorbet/StrictSigil:
3232
- "lib/**/*.rb"
3333
Exclude:
3434
- "**/*.rake"
35+
- "lib/ruby_lsp/ruby_lsp_rspec/rspec_formatter.rb"
3536
- "spec/**/*.rb"
3637

3738
Style/StderrPuts:

Gemfile.lock

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ PATH
22
remote: .
33
specs:
44
ruby-lsp-rspec (0.1.22)
5-
ruby-lsp (~> 0.23.0)
5+
ruby-lsp (~> 0.23.17)
66

77
GEM
88
remote: https://rubygems.org/
@@ -43,7 +43,7 @@ GEM
4343
prism (~> 1.0)
4444
rbs (>= 3.4.4)
4545
sorbet-runtime (>= 0.5.9204)
46-
rbs (3.9.2)
46+
rbs (3.9.3)
4747
logger
4848
rdoc (6.13.1)
4949
psych (>= 4.0.0)
@@ -83,20 +83,20 @@ GEM
8383
rubocop (~> 1.62)
8484
rubocop-sorbet (0.10.0)
8585
rubocop (>= 1)
86-
ruby-lsp (0.23.15)
86+
ruby-lsp (0.23.17)
8787
language_server-protocol (~> 3.17.0)
8888
prism (>= 1.2, < 2.0)
8989
rbs (>= 3, < 4)
9090
sorbet-runtime (>= 0.5.10782)
9191
ruby-progressbar (1.13.0)
92-
sorbet (0.5.12043)
93-
sorbet-static (= 0.5.12043)
94-
sorbet-runtime (0.5.12043)
95-
sorbet-static (0.5.12043-universal-darwin)
96-
sorbet-static (0.5.12043-x86_64-linux)
97-
sorbet-static-and-runtime (0.5.12043)
98-
sorbet (= 0.5.12043)
99-
sorbet-runtime (= 0.5.12043)
92+
sorbet (0.5.12048)
93+
sorbet-static (= 0.5.12048)
94+
sorbet-runtime (0.5.12048)
95+
sorbet-static (0.5.12048-universal-darwin)
96+
sorbet-static (0.5.12048-x86_64-linux)
97+
sorbet-static-and-runtime (0.5.12048)
98+
sorbet (= 0.5.12048)
99+
sorbet-runtime (= 0.5.12048)
100100
spoom (1.6.1)
101101
erubi (>= 1.10.0)
102102
prism (>= 0.28.0)

lib/ruby_lsp/ruby_lsp_rspec/addon.rb

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@
88
require_relative "document_symbol"
99
require_relative "definition"
1010
require_relative "indexing_enhancement"
11+
require_relative "test_discovery"
12+
require_relative "spec_style_patch"
1113

1214
module RubyLsp
1315
module RSpec
1416
class Addon < ::RubyLsp::Addon
1517
extend T::Sig
1618

19+
FORMATTER_PATH = T.let(File.expand_path("rspec_formatter.rb", __dir__), String)
20+
FORMATTER_NAME = T.let("RubyLsp::RSpec::RSpecFormatter", String)
21+
1722
sig { returns(T::Boolean) }
1823
attr_reader :debug
1924

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

3136
settings = global_state.settings_for_addon(name)
3237
@rspec_command = rspec_command(settings)
38+
@workspace_path = T.let(global_state.workspace_path, T.nilable(String))
3339
@debug = settings&.dig(:debug) || false
3440
end
3541

3642
sig { override.void }
3743
def deactivate; end
3844

45+
sig { override.returns(String) }
46+
def name
47+
"ruby-lsp-rspec"
48+
end
49+
3950
sig { override.returns(String) }
4051
def version
4152
VERSION
@@ -55,6 +66,68 @@ def create_code_lens_listener(response_builder, uri, dispatcher)
5566
CodeLens.new(response_builder, uri, dispatcher, T.must(@rspec_command), debug: debug)
5667
end
5768

69+
# Creates a new Discover Tests listener. This method is invoked on every DiscoverTests request
70+
sig do
71+
override.params(
72+
response_builder: ResponseBuilders::TestCollection,
73+
dispatcher: Prism::Dispatcher,
74+
uri: URI::Generic,
75+
).void
76+
end
77+
def create_discover_tests_listener(response_builder, dispatcher, uri)
78+
return unless uri.to_standardized_path&.end_with?("_spec.rb")
79+
80+
TestDiscovery.new(response_builder, dispatcher, uri, T.must(@workspace_path))
81+
end
82+
83+
# Resolves the minimal set of commands required to execute the requested tests
84+
sig do
85+
override.params(
86+
items: T::Array[T::Hash[Symbol, T.untyped]],
87+
).returns(T::Array[String])
88+
end
89+
def resolve_test_commands(items)
90+
commands = []
91+
queue = items.dup
92+
93+
full_files = []
94+
95+
until queue.empty?
96+
item = T.must(queue.shift)
97+
tags = Set.new(item[:tags])
98+
next unless tags.include?("framework:rspec")
99+
100+
children = item[:children]
101+
uri = URI(item[:uri])
102+
path = uri.full_path
103+
next unless path
104+
105+
if tags.include?("test_dir")
106+
if children.empty?
107+
full_files.concat(Dir.glob(
108+
"#{path}/**/*_spec.rb",
109+
File::Constants::FNM_EXTGLOB | File::Constants::FNM_PATHNAME,
110+
))
111+
end
112+
elsif tags.include?("test_file")
113+
full_files << path if children.empty?
114+
elsif tags.include?("test_group")
115+
start_line = item.dig(:range, :start, :line)
116+
commands << "#{@rspec_command} -r #{FORMATTER_PATH} -f #{FORMATTER_NAME} #{path}:#{start_line + 1}"
117+
else
118+
full_files << "#{path}:#{item.dig(:range, :start, :line) + 1}"
119+
end
120+
121+
queue.concat(children)
122+
end
123+
124+
unless full_files.empty?
125+
commands << "#{@rspec_command} -r #{FORMATTER_PATH} -f #{FORMATTER_NAME} #{full_files.join(" ")}"
126+
end
127+
128+
commands
129+
end
130+
58131
sig do
59132
override.params(
60133
response_builder: ResponseBuilders::DocumentSymbol,
@@ -67,10 +140,7 @@ def create_document_symbol_listener(response_builder, dispatcher)
67140

68141
sig do
69142
override.params(
70-
response_builder: ResponseBuilders::CollectionResponseBuilder[T.any(
71-
Interface::Location,
72-
Interface::LocationLink,
73-
)],
143+
response_builder: ResponseBuilders::CollectionResponseBuilder[T.any(Interface::Location, Interface::LocationLink)],
74144
uri: URI::Generic,
75145
node_context: NodeContext,
76146
dispatcher: Prism::Dispatcher,
@@ -82,10 +152,7 @@ def create_definition_listener(response_builder, uri, node_context, dispatcher)
82152
Definition.new(response_builder, uri, node_context, T.must(@index), dispatcher)
83153
end
84154

85-
sig { override.returns(String) }
86-
def name
87-
"Ruby LSP RSpec"
88-
end
155+
private
89156

90157
sig { params(settings: T.nilable(T::Hash[Symbol, T.untyped])).returns(String) }
91158
def rspec_command(settings)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "rspec/core/formatters"
5+
require "ruby_lsp/test_reporters/lsp_reporter"
6+
7+
module RubyLsp
8+
module RSpec
9+
class RSpecFormatter
10+
::RSpec::Core::Formatters.register(
11+
self,
12+
:example_passed,
13+
:example_pending,
14+
:example_failed,
15+
:example_started,
16+
:stop,
17+
)
18+
19+
def initialize(output)
20+
@output = output
21+
end
22+
23+
def example_started(notification)
24+
example = notification.example
25+
uri = uri_for(example)
26+
id = generate_id(example)
27+
RubyLsp::LspReporter.instance.start_test(id: id, uri: uri)
28+
end
29+
30+
def example_passed(notification)
31+
example = notification.example
32+
uri = uri_for(example)
33+
id = generate_id(example)
34+
RubyLsp::LspReporter.instance.record_pass(id: id, uri: uri)
35+
end
36+
37+
def example_failed(notification)
38+
example = notification.example
39+
uri = uri_for(example)
40+
id = generate_id(example)
41+
RubyLsp::LspReporter.instance.record_fail(id: id, message: notification.exception.message, uri: uri)
42+
end
43+
44+
def example_pending(notification)
45+
example = notification.example
46+
uri = uri_for(example)
47+
id = generate_id(example)
48+
RubyLsp::LspReporter.instance.record_skip(id: id, uri: uri)
49+
end
50+
51+
def stop(notification)
52+
RubyLsp::LspReporter.instance.shutdown
53+
end
54+
55+
def uri_for(example)
56+
absolute_path = File.expand_path(example.file_path)
57+
URI::Generic.from_path(path: absolute_path)
58+
end
59+
60+
def generate_id(example)
61+
[example, *example.example_group.parent_groups].reverse.map(&:location).join("::")
62+
end
63+
end
64+
end
65+
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Listeners
6+
# Patching this listener so it doesn't generate test items for RSpec tests
7+
class SpecStyle
8+
extend T::Sig
9+
10+
sig { params(response_builder: ResponseBuilders::TestCollection, global_state: GlobalState, dispatcher: Prism::Dispatcher, uri: URI::Generic).void }
11+
def initialize(response_builder, global_state, dispatcher, uri)
12+
super
13+
end
14+
end
15+
end
16+
end

0 commit comments

Comments
 (0)