-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy pathcode_lens.rb
More file actions
308 lines (268 loc) · 10.1 KB
/
code_lens.rb
File metadata and controls
308 lines (268 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# typed: strict
# frozen_string_literal: true
module RubyLsp
module Rails
# 
#
# This feature adds Code Lens features for Rails applications.
#
# For Active Support test cases:
#
# - Run tests in the VS Terminal
# - Run tests in the VS Code Test Explorer
# - Debug tests
# - Run migrations in the VS Terminal
#
# For Rails controllers:
#
# - See the path corresponding to an action
# - Click on the action's Code Lens to jump to its declaration in the routes.
#
# Note: This depends on a support for the `rubyLsp.openFile` command.
# For the VS Code extension this is built-in, but for other editors this may require some custom configuration.
#
# The
# [code lens](https://microsoft.github.io/language-server-protocol/specification#textDocument_codeLens)
# request informs the editor of runnable commands such as tests.
# It's available for tests which inherit from `ActiveSupport::TestCase` or one of its descendants, such as
# `ActionDispatch::IntegrationTest`.
#
# # Example:
#
# For the following code, Code Lenses will be added above the class definition above each test method.
#
# ```ruby
# Run
# class HelloTest < ActiveSupport::TestCase # <- Will show code lenses above for running or debugging the whole test
# test "outputs hello" do # <- Will show code lenses above for running or debugging this test
# # ...
# end
#
# test "outputs goodbye" do # <- Will show code lenses above for running or debugging this test
# # ...
# end
# end
# ```
#
# # Example:
# ```ruby
# Run
# class AddFirstNameToUsers < ActiveRecord::Migration[7.1]
# # ...
# end
# ```
#
# The code lenses will be displayed above the class and above each test method.
#
# Note: When using the Test Explorer view, if your code contains a statement to pause execution (e.g. `debugger`) it
# will cause the test runner to hang.
#
# For the following code, assuming the routing contains `resources :users`, a Code Lens will be seen above each
# action.
#
# ```ruby
# class UsersController < ApplicationController
# GET /users(.:format)
# def index # <- Will show code lens above for the path
# end
# end
# ```
#
# Note: Complex routing configurations may not be supported.
#
class CodeLens
include Requests::Support::Common
include Inflections
include ActiveSupportTestCaseHelper
#: (RunnerClient, GlobalState, ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens], URI::Generic, Prism::Dispatcher) -> void
def initialize(client, global_state, response_builder, uri, dispatcher)
@client = client
@global_state = global_state
@response_builder = response_builder
@path = uri.to_standardized_path #: String?
@group_id = 1 #: Integer
@group_id_stack = [] #: Array[Integer]
@constant_name_stack = [] #: Array[[String, String?]]
dispatcher.register(
self,
:on_call_node_enter,
:on_class_node_enter,
:on_def_node_enter,
:on_class_node_leave,
:on_module_node_enter,
:on_module_node_leave,
)
end
#: (Prism::CallNode node) -> void
def on_call_node_enter(node)
# Remove this method once the rollout is complete
return if @global_state.enabled_feature?(:fullTestDiscovery)
content = extract_test_case_name(node)
return unless content
line_number = node.location.start_line
command = "#{test_command} #{@path}:#{line_number}"
add_test_code_lens(node, name: content, command: command, kind: :example)
end
# Although uncommon, Rails tests can be written with the classic "def test_name" syntax.
#: (Prism::DefNode node) -> void
def on_def_node_enter(node)
# Remove this entire unless block once the rollout is complete
unless @global_state.enabled_feature?(:fullTestDiscovery)
method_name = node.name.to_s
if method_name.start_with?("test_")
line_number = node.location.start_line
command = "#{test_command} #{@path}:#{line_number}"
add_test_code_lens(node, name: method_name, command: command, kind: :example)
end
end
if controller?
add_route_code_lens_to_action(node)
add_jump_to_view(node)
end
end
#: (Prism::ClassNode node) -> void
def on_class_node_enter(node)
class_name = node.constant_path.slice
superclass_name = node.superclass&.slice
# We need to use a stack because someone could define a nested class
# inside a controller. When we exit that nested class declaration, we are
# back in a controller context. This part is used in other places in the LSP
@constant_name_stack << [class_name, superclass_name]
# Remove this entire if block once the rollout is complete
if class_name.end_with?("Test") && !@global_state.enabled_feature?(:fullTestDiscovery)
fully_qualified_name = @constant_name_stack.map(&:first).join("::")
command = "#{test_command} #{@path} --name \"/#{Shellwords.escape(fully_qualified_name)}(#|::)/\""
add_test_code_lens(node, name: class_name, command: command, kind: :group)
@group_id_stack.push(@group_id)
@group_id += 1
end
if @path && superclass_name&.start_with?("ActiveRecord::Migration")
command = "#{migrate_command} VERSION=#{migration_version}"
add_migrate_code_lens(node, name: class_name, command: command)
end
end
#: (Prism::ClassNode node) -> void
def on_class_node_leave(node)
class_name = node.constant_path.slice
if class_name.end_with?("Test")
@group_id_stack.pop
end
# Remove everything but the `@constant_name_stack.pop` once the rollout is complete
return if @global_state.enabled_feature?(:fullTestDiscovery)
@constant_name_stack.pop
end
#: (Prism::ModuleNode node) -> void
def on_module_node_enter(node)
@constant_name_stack << [node.constant_path.slice, nil]
end
#: (Prism::ModuleNode node) -> void
def on_module_node_leave(node)
@constant_name_stack.pop
end
private
#: -> bool?
def controller?
class_name, superclass_name = @constant_name_stack.last
return false unless class_name && superclass_name
class_name.end_with?("Controller") && superclass_name.end_with?("Controller")
end
#: (Prism::DefNode node) -> void
def add_jump_to_view(node)
class_name = @constant_name_stack.map(&:first).join("::")
action_name = node.name
controller_name = underscore(class_name.delete_suffix("Controller"))
view_uris = Dir.glob("#{@client.views_dir}/#{controller_name}/#{action_name}*").filter_map do |path|
# it's possible we could have a directory with the same name as the action, so we need to skip those
next if File.directory?(path)
URI::Generic.from_path(path: path).to_s
end
return if view_uris.empty?
@response_builder << create_code_lens(
node,
title: "Jump to view",
command_name: "rubyLsp.openFile",
arguments: [view_uris],
data: { type: "file" },
)
end
#: (Prism::DefNode node) -> void
def add_route_code_lens_to_action(node)
class_name, _ = @constant_name_stack.last #: as !nil
route = @client.route(controller: class_name, action: node.name.to_s)
return unless route
file_path, line = route[:source_location]
@response_builder << create_code_lens(
node,
title: "#{route[:verb]} #{route[:path]}",
command_name: "rubyLsp.openFile",
arguments: [["file://#{file_path}#L#{line}"]],
data: { type: "file" },
)
end
#: -> String
def test_command
"#{RbConfig.ruby} bin/rails test"
end
#: -> String
def migrate_command
"#{RbConfig.ruby} bin/rails db:migrate"
end
#: -> String?
def migration_version
File.basename(
@path, #: as !nil
).split("_").first
end
#: (Prism::Node node, name: String, command: String) -> void
def add_migrate_code_lens(node, name:, command:)
return unless @path
@response_builder << create_code_lens(
node,
title: "▶ Run",
command_name: "rubyLsp.runTask",
arguments: [command],
data: { type: "migrate" },
)
end
#: (Prism::Node node, name: String, command: String, kind: Symbol) -> void
def add_test_code_lens(node, name:, command:, kind:)
return unless @path
return unless @global_state.test_library == "rails"
arguments = [
@path,
name,
command,
{
start_line: node.location.start_line - 1,
start_column: node.location.start_column,
end_line: node.location.end_line - 1,
end_column: node.location.end_column,
},
]
grouping_data = { group_id: @group_id_stack.last, kind: kind }
grouping_data[:id] = @group_id if kind == :group
@response_builder << create_code_lens(
node,
title: "▶ Run",
command_name: "rubyLsp.runTest",
arguments: arguments,
data: { type: "test", **grouping_data },
)
@response_builder << create_code_lens(
node,
title: "▶ Run In Terminal",
command_name: "rubyLsp.runTestInTerminal",
arguments: arguments,
data: { type: "test_in_terminal", **grouping_data },
)
@response_builder << create_code_lens(
node,
title: "Debug",
command_name: "rubyLsp.debugTest",
arguments: arguments,
data: { type: "debug", **grouping_data },
)
end
end
end
end