Skip to content

Commit 30624fa

Browse files
committed
Add Code Lens behaviour for running Rails tests
1 parent b9ba758 commit 30624fa

File tree

12 files changed

+563
-235
lines changed

12 files changed

+563
-235
lines changed

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ PATH
33
specs:
44
ruby-lsp-rails (0.1.0)
55
rails (>= 6.0)
6-
ruby-lsp (>= 0.5.1, < 0.7.0)
6+
ruby-lsp (>= 0.6.2, < 0.7.0)
77
sorbet-runtime (>= 0.5.9897)
88

99
GEM
@@ -195,7 +195,7 @@ GEM
195195
rubocop (~> 1.51)
196196
rubocop-sorbet (0.7.0)
197197
rubocop (>= 0.90.0)
198-
ruby-lsp (0.6.1)
198+
ruby-lsp (0.6.2)
199199
language_server-protocol (~> 3.17.0)
200200
sorbet-runtime
201201
syntax_tree (>= 6.1.1, < 7)

Rakefile

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,6 @@ RDoc::Task.new do |rdoc|
2727
rdoc.options.push("--copy-files", "LICENSE.txt")
2828
end
2929

30-
RubyLsp::CheckDocs.new(
31-
FileList["#{__dir__}/lib/ruby_lsp/ruby_lsp_rails/**/*.rb"],
32-
FileList["#{__dir__}/**/*.gif"],
33-
)
30+
RubyLsp::CheckDocs.new(FileList["#{__dir__}/lib/ruby_lsp/**/*.rb"], FileList["#{__dir__}/misc/**/*.gif"])
3431

3532
task default: :test
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
# ![CodeLens demo](../../code_lens.gif)
7+
#
8+
# This feature adds several CodeLens features for Rails applications using the built-in test framework:
9+
# - Run tests in the VS Terminal
10+
# - Run tests in the VS Code Test Explorer
11+
# - Debug tests
12+
#
13+
# The
14+
# [code lens](https://microsoft.github.io/language-server-protocol/specification#textDocument_codeLens)
15+
# request informs the editor of runnable commands such as tests
16+
#
17+
# Example:
18+
#
19+
# For the following code, Code Lenses will be added above the class definition above each test method.
20+
#
21+
# ```ruby
22+
# Run
23+
# class HelloTest < ActiveSupport::TestCase
24+
# test "outputs hello" do
25+
# # ...
26+
# end
27+
#
28+
# test "outputs goodbye" do
29+
# # ...
30+
# end
31+
# end
32+
# ````
33+
#
34+
# The code lenses will be displayed above the class and above each test method.
35+
class CodeLens < ::RubyLsp::Listener
36+
extend T::Sig
37+
extend T::Generic
38+
39+
ResponseType = type_member { { fixed: T::Array[::RubyLsp::Interface::CodeLens] } }
40+
BASE_COMMAND = "bin/rails test"
41+
42+
::RubyLsp::Requests::CodeLens.add_listener(self)
43+
44+
sig { override.returns(ResponseType) }
45+
attr_reader :response
46+
47+
sig { params(uri: String, emitter: EventEmitter, message_queue: Thread::Queue).void }
48+
def initialize(uri, emitter, message_queue)
49+
@response = T.let([], ResponseType)
50+
@visibility = T.let("public", String)
51+
@prev_visibility = T.let("public", String)
52+
@path = T.let(URI(uri).path, T.nilable(String))
53+
@relative_path = T.let(Pathname.new(@path).relative_path_from(RailsClient.instance.root).to_s, String)
54+
emitter.register(self, :on_command, :on_class, :on_def)
55+
56+
super(emitter, message_queue)
57+
end
58+
59+
sig { params(node: SyntaxTree::Command).void }
60+
def on_command(node)
61+
return unless @visibility == "public"
62+
63+
message_value = node.message.value
64+
return unless message_value == "test" && node.arguments.parts.any?
65+
66+
first_argument = node.arguments.parts.first
67+
return unless first_argument.is_a?(SyntaxTree::StringLiteral)
68+
69+
test_name = first_argument.parts.first.value
70+
return unless test_name
71+
72+
line_number = node.location.start_line
73+
command = "#{BASE_COMMAND} #{@relative_path}:#{line_number}"
74+
add_test_code_lens(node, name: test_name, command: command, kind: :example)
75+
end
76+
77+
# Although uncommon, Rails tests can be written with the classic "def test_name" syntax.
78+
sig { params(node: SyntaxTree::DefNode).void }
79+
def on_def(node)
80+
method_name = node.name.value
81+
if method_name.start_with?("test_")
82+
line_number = node.location.start_line
83+
command = "#{BASE_COMMAND} #{@relative_path}:#{line_number}"
84+
add_test_code_lens(node, name: method_name, command: command, kind: :example)
85+
end
86+
end
87+
88+
sig { params(node: SyntaxTree::ClassDeclaration).void }
89+
def on_class(node)
90+
class_name = node.constant.constant.value
91+
if class_name.end_with?("Test")
92+
command = "#{BASE_COMMAND} #{@relative_path}"
93+
add_test_code_lens(node, name: class_name, command: command, kind: :group)
94+
end
95+
end
96+
97+
private
98+
99+
sig { params(node: SyntaxTree::Node, name: String, command: String, kind: Symbol).void }
100+
def add_test_code_lens(node, name:, command:, kind:)
101+
arguments = [
102+
@path,
103+
name,
104+
command,
105+
{
106+
start_line: node.location.start_line - 1,
107+
start_column: node.location.start_column,
108+
end_line: node.location.end_line - 1,
109+
end_column: node.location.end_column,
110+
},
111+
]
112+
113+
@response << create_code_lens(
114+
node,
115+
title: "Run",
116+
command_name: "rubyLsp.runTest",
117+
arguments: arguments,
118+
data: { type: "test", kind: kind },
119+
)
120+
121+
@response << create_code_lens(
122+
node,
123+
title: "Run In Terminal",
124+
command_name: "rubyLsp.runTestInTerminal",
125+
arguments: arguments,
126+
data: { type: "test_in_terminal", kind: kind },
127+
)
128+
129+
@response << create_code_lens(
130+
node,
131+
title: "Debug",
132+
command_name: "rubyLsp.debugTest",
133+
arguments: arguments,
134+
data: { type: "debug", kind: kind },
135+
)
136+
end
137+
end
138+
end
139+
end

lib/ruby_lsp/ruby_lsp_rails/extension.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
require_relative "rails_client"
77
require_relative "hover"
8+
require_relative "code_lens"
89

910
module RubyLsp
1011
module Rails
@@ -13,6 +14,7 @@ class Extension < ::RubyLsp::Extension
1314

1415
sig { override.void }
1516
def activate
17+
# Must be the last statement in activate since it raises to display a notification for the user
1618
RubyLsp::Rails::RailsClient.instance.check_if_server_is_running!
1719
end
1820

lib/ruby_lsp/ruby_lsp_rails/rails_client.rb

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
module RubyLsp
88
module Rails
99
class RailsClient
10-
class ServerAddressUnknown < StandardError; end
10+
class ServerNotRunningError < StandardError; end
11+
class NeedsRestartError < StandardError; end
1112

1213
extend T::Sig
1314
include Singleton
@@ -30,16 +31,21 @@ def initialize
3031
@root = T.let(Dir.exist?(dummy_path) ? dummy_path : project_root.to_s, String)
3132
app_uri_path = "#{@root}/tmp/app_uri.txt"
3233

33-
if File.exist?(app_uri_path)
34-
url = File.read(app_uri_path).chomp
34+
unless File.exist?(app_uri_path)
35+
raise NeedsRestartError, <<~MESSAGE
36+
The Ruby LSP Rails extension needs to be initialized. Please restart the Rails server and the Ruby LSP
37+
to get Rails features in the editor
38+
MESSAGE
39+
end
3540

36-
scheme, rest = url.split("://")
37-
uri, port = T.must(rest).split(":")
41+
url = File.read(app_uri_path).chomp
3842

39-
@ssl = T.let(scheme == "https", T::Boolean)
40-
@uri = T.let(T.must(uri), T.nilable(String))
41-
@port = T.let(T.must(port).to_i, Integer)
42-
end
43+
scheme, rest = url.split("://")
44+
uri, port = T.must(rest).split(":")
45+
46+
@ssl = T.let(scheme == "https", T::Boolean)
47+
@uri = T.let(T.must(uri), String)
48+
@port = T.let(T.must(port).to_i, Integer)
4349
end
4450

4551
sig { params(name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
@@ -48,15 +54,15 @@ def model(name)
4854
return unless response.code == "200"
4955

5056
JSON.parse(response.body.chomp, symbolize_names: true)
51-
rescue Errno::ECONNREFUSED, ServerAddressUnknown
52-
nil
57+
rescue Errno::ECONNREFUSED
58+
raise ServerNotRunningError, SERVER_NOT_RUNNING_MESSAGE
5359
end
5460

5561
sig { void }
5662
def check_if_server_is_running!
5763
request("activate", 0.2)
58-
rescue Errno::ECONNREFUSED, ServerAddressUnknown
59-
warn(SERVER_NOT_RUNNING_MESSAGE)
64+
rescue Errno::ECONNREFUSED
65+
raise ServerNotRunningError, SERVER_NOT_RUNNING_MESSAGE
6066
rescue Net::ReadTimeout
6167
# If the server is running, but the initial request is taking too long, we don't want to block the
6268
# initialization of the Ruby LSP
@@ -66,8 +72,6 @@ def check_if_server_is_running!
6672

6773
sig { params(path: String, timeout: T.nilable(Float)).returns(Net::HTTPResponse) }
6874
def request(path, timeout = nil)
69-
raise ServerAddressUnknown unless @uri
70-
7175
http = Net::HTTP.new(@uri, @port)
7276
http.use_ssl = @ssl
7377
http.read_timeout = timeout if timeout

lib/ruby_lsp_rails/railtie.rb

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,7 @@ class Railtie < ::Rails::Railtie
1414
if defined?(::Rails::Server)
1515
ssl_enable, host, port = ::Rails::Server::Options.new.parse!(ARGV).values_at(:SSLEnable, :Host, :Port)
1616
app_uri = "#{ssl_enable ? "https" : "http"}://#{host}:#{port}"
17-
app_uri_path = "#{::Rails.root}/tmp/app_uri.txt"
18-
File.write(app_uri_path, app_uri)
19-
20-
at_exit do
21-
# The app_uri.txt file should only exist when the server is running. The extension uses its presence to
22-
# report if the server is running or not. If the server is not running, some of the extension features
23-
# will not be available.
24-
File.delete(app_uri_path) if File.exist?(app_uri_path)
25-
end
17+
File.write("#{::Rails.root}/tmp/app_uri.txt", app_uri)
2618
end
2719
end
2820
end

misc/code_lens.gif

Loading

ruby-lsp-rails.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ Gem::Specification.new do |spec|
2222
end
2323

2424
spec.add_dependency("rails", ">= 6.0")
25-
spec.add_dependency("ruby-lsp", ">= 0.5.1", "< 0.7.0")
25+
spec.add_dependency("ruby-lsp", ">= 0.6.2", "< 0.7.0")
2626
spec.add_dependency("sorbet-runtime", ">= 0.5.9897")
2727
end

0 commit comments

Comments
 (0)