-
Notifications
You must be signed in to change notification settings - Fork 179
/
Copy pathcode_action_resolve.rb
334 lines (283 loc) · 12.9 KB
/
code_action_resolve.rb
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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# typed: strict
# frozen_string_literal: true
module RubyLsp
module Requests
# The [code action resolve](https://microsoft.github.io/language-server-protocol/specification#codeAction_resolve)
# request is used to to resolve the edit field for a given code action, if it is not already provided in the
# textDocument/codeAction response. We can use it for scenarios that require more computation such as refactoring.
class CodeActionResolve < Request
extend T::Sig
include Support::Common
NEW_VARIABLE_NAME = "new_variable"
NEW_METHOD_NAME = "new_method"
class CodeActionError < StandardError; end
class Error < ::T::Enum
enums do
EmptySelection = new
InvalidTargetRange = new
UnknownCodeAction = new
end
end
sig { params(document: RubyDocument, global_state: GlobalState, code_action: T::Hash[Symbol, T.untyped]).void }
def initialize(document, global_state, code_action)
super()
@document = document
@global_state = global_state
@code_action = code_action
end
sig { override.returns(T.any(Interface::CodeAction, Error)) }
def perform
return Error::EmptySelection if @document.source.empty?
case @code_action[:title]
when CodeActions::EXTRACT_TO_VARIABLE_TITLE
refactor_variable
when CodeActions::EXTRACT_TO_METHOD_TITLE
refactor_method
when CodeActions::TOGGLE_BLOCK_STYLE_TITLE
switch_block_style
else
Error::UnknownCodeAction
end
end
private
sig { returns(T.any(Interface::CodeAction, Error)) }
def switch_block_style
source_range = @code_action.dig(:data, :range)
return Error::EmptySelection if source_range[:start] == source_range[:end]
target = @document.locate_first_within_range(
@code_action.dig(:data, :range),
node_types: [Prism::CallNode],
)
return Error::InvalidTargetRange unless target.is_a?(Prism::CallNode)
node = target.block
return Error::InvalidTargetRange unless node.is_a?(Prism::BlockNode)
indentation = " " * target.location.start_column unless node.opening_loc.slice == "do"
Interface::CodeAction.new(
title: CodeActions::TOGGLE_BLOCK_STYLE_TITLE,
edit: Interface::WorkspaceEdit.new(
document_changes: [
Interface::TextDocumentEdit.new(
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: @code_action.dig(:data, :uri),
version: nil,
),
edits: [
Interface::TextEdit.new(
range: range_from_location(node.location),
new_text: recursively_switch_nested_block_styles(node, indentation),
),
],
),
],
),
)
end
sig { returns(T.any(Interface::CodeAction, Error)) }
def refactor_variable
source_range = @code_action.dig(:data, :range)
return Error::EmptySelection if source_range[:start] == source_range[:end]
scanner = @document.create_scanner
start_index = scanner.find_char_position(source_range[:start])
end_index = scanner.find_char_position(source_range[:end])
extracted_source = T.must(@document.source[start_index...end_index])
# Find the closest statements node, so that we place the refactor in a valid position
node_context = RubyDocument
.locate(@document.parse_result.value,
start_index,
node_types: [
Prism::StatementsNode,
Prism::BlockNode,
],
code_units_cache: @document.code_units_cache)
closest_statements = node_context.node
parent_statements = node_context.parent
return Error::InvalidTargetRange if closest_statements.nil? || closest_statements.child_nodes.compact.empty?
# Find the node with the end line closest to the requested position, so that we can place the refactor
# immediately after that closest node
closest_node = T.must(closest_statements.child_nodes.compact.min_by do |node|
distance = source_range.dig(:start, :line) - (node.location.end_line - 1)
distance <= 0 ? Float::INFINITY : distance
end)
return Error::InvalidTargetRange if closest_node.is_a?(Prism::MissingNode)
closest_node_loc = closest_node.location
# If the parent expression is a single line block, then we have to extract it inside of the one-line block
if parent_statements.is_a?(Prism::BlockNode) &&
parent_statements.location.start_line == parent_statements.location.end_line
variable_source = " #{NEW_VARIABLE_NAME} = #{extracted_source};"
character = source_range.dig(:start, :character) - 1
target_range = {
start: { line: closest_node_loc.end_line - 1, character: character },
end: { line: closest_node_loc.end_line - 1, character: character },
}
else
# If the closest node covers the requested location, then we're extracting a statement nested inside of it. In
# that case, we want to place the extraction at the start of the closest node (one line above). Otherwise, we
# want to place the extract right below the closest node
if closest_node_loc.start_line - 1 <= source_range.dig(
:start,
:line,
) && closest_node_loc.end_line - 1 >= source_range.dig(:end, :line)
indentation_line_number = closest_node_loc.start_line - 1
target_line = indentation_line_number
else
target_line = closest_node_loc.end_line
indentation_line_number = closest_node_loc.end_line - 1
end
lines = @document.source.lines
indentation_line = lines[indentation_line_number]
return Error::InvalidTargetRange unless indentation_line
indentation = T.must(indentation_line[/\A */]).size
target_range = {
start: { line: target_line, character: indentation },
end: { line: target_line, character: indentation },
}
line = lines[target_line]
return Error::InvalidTargetRange unless line
variable_source = if line.strip.empty?
"\n#{" " * indentation}#{NEW_VARIABLE_NAME} = #{extracted_source}"
else
"#{NEW_VARIABLE_NAME} = #{extracted_source}\n#{" " * indentation}"
end
end
Interface::CodeAction.new(
title: CodeActions::EXTRACT_TO_VARIABLE_TITLE,
edit: Interface::WorkspaceEdit.new(
document_changes: [
Interface::TextDocumentEdit.new(
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: @code_action.dig(:data, :uri),
version: nil,
),
edits: [
create_text_edit(source_range, NEW_VARIABLE_NAME),
create_text_edit(target_range, variable_source),
],
),
],
),
)
end
sig { returns(T.any(Interface::CodeAction, Error)) }
def refactor_method
source_range = @code_action.dig(:data, :range)
return Error::EmptySelection if source_range[:start] == source_range[:end]
scanner = @document.create_scanner
start_index = scanner.find_char_position(source_range[:start])
end_index = scanner.find_char_position(source_range[:end])
extracted_source = T.must(@document.source[start_index...end_index])
# Find the closest method declaration node, so that we place the refactor in a valid position
node_context = RubyDocument.locate(
@document.parse_result.value,
start_index,
node_types: [Prism::DefNode],
code_units_cache: @document.code_units_cache,
)
closest_node = node_context.node
return Error::InvalidTargetRange unless closest_node
target_range = if closest_node.is_a?(Prism::DefNode)
end_keyword_loc = closest_node.end_keyword_loc
return Error::InvalidTargetRange unless end_keyword_loc
end_line = end_keyword_loc.end_line - 1
character = end_keyword_loc.end_column
indentation = " " * end_keyword_loc.start_column
new_method_source = <<~RUBY.chomp
#{indentation}def #{NEW_METHOD_NAME}
#{indentation} #{extracted_source}
#{indentation}end
RUBY
{
start: { line: end_line, character: character },
end: { line: end_line, character: character },
}
else
new_method_source = <<~RUBY
#{indentation}def #{NEW_METHOD_NAME}
#{indentation} #{extracted_source.gsub("\n", "\n ")}
#{indentation}end
RUBY
line = [0, source_range.dig(:start, :line) - 1].max
{
start: { line: line, character: source_range.dig(:start, :character) },
end: { line: line, character: source_range.dig(:start, :character) },
}
end
Interface::CodeAction.new(
title: CodeActions::EXTRACT_TO_METHOD_TITLE,
edit: Interface::WorkspaceEdit.new(
document_changes: [
Interface::TextDocumentEdit.new(
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: @code_action.dig(:data, :uri),
version: nil,
),
edits: [
create_text_edit(target_range, new_method_source),
create_text_edit(source_range, NEW_METHOD_NAME),
],
),
],
),
)
end
sig { params(range: T::Hash[Symbol, T.untyped], new_text: String).returns(Interface::TextEdit) }
def create_text_edit(range, new_text)
Interface::TextEdit.new(
range: Interface::Range.new(
start: Interface::Position.new(line: range.dig(:start, :line), character: range.dig(:start, :character)),
end: Interface::Position.new(line: range.dig(:end, :line), character: range.dig(:end, :character)),
),
new_text: new_text,
)
end
sig { params(node: Prism::BlockNode, indentation: T.nilable(String)).returns(String) }
def recursively_switch_nested_block_styles(node, indentation)
parameters = node.parameters
body = node.body
# We use the indentation to differentiate between do...end and brace style blocks because only the do...end
# style requires the indentation to build the edit.
#
# If the block is using `do...end` style, we change it to a single line brace block. Newlines are turned into
# semi colons, so that the result is valid Ruby code and still a one liner. If the block is using brace style,
# we do the opposite and turn it into a `do...end` block, making all semi colons into newlines.
source = +""
if indentation
source << "do"
source << " #{parameters.slice}" if parameters
source << "\n#{indentation} "
source << switch_block_body(body, indentation) if body
source << "\n#{indentation}end"
else
source << "{ "
source << "#{parameters.slice} " if parameters
source << switch_block_body(body, nil) if body
source << "}"
end
source
end
sig { params(body: Prism::Node, indentation: T.nilable(String)).returns(String) }
def switch_block_body(body, indentation)
# Check if there are any nested blocks inside of the current block
body_loc = body.location
nested_block = @document.locate_first_within_range(
{
start: { line: body_loc.start_line - 1, character: body_loc.start_column },
end: { line: body_loc.end_line - 1, character: body_loc.end_column },
},
node_types: [Prism::BlockNode],
)
body_content = body.slice.dup
# If there are nested blocks, then we change their style too and we have to mutate the string using the
# relative position in respect to the beginning of the body
if nested_block.is_a?(Prism::BlockNode)
location = nested_block.location
correction_start = location.start_offset - body_loc.start_offset
correction_end = location.end_offset - body_loc.start_offset
next_indentation = indentation ? "#{indentation} " : nil
body_content[correction_start...correction_end] =
recursively_switch_nested_block_styles(nested_block, next_indentation)
end
indentation ? body_content.gsub(";", "\n") : "#{body_content.gsub("\n", ";")} "
end
end
end
end