ruby-lsp-refactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +327 -0
- data/Rakefile +16 -0
- data/lib/ruby/lsp/refactor/version.rb +9 -0
- data/lib/ruby/lsp/refactor.rb +12 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +97 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +65 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/conditional_listener.rb +250 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +97 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +316 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +61 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +134 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +101 -0
- data/lib/ruby_lsp/test_helper.rb +50 -0
- data/sig/ruby/lsp/refactor.rbs +8 -0
- data/test/ruby_lsp_refactor/array_listener_test.rb +82 -0
- data/test/ruby_lsp_refactor/conditional_listener_test.rb +307 -0
- data/test/ruby_lsp_refactor/hash_listener_test.rb +72 -0
- data/test/ruby_lsp_refactor/method_listener_test.rb +193 -0
- data/test/ruby_lsp_refactor/string_listener_test.rb +70 -0
- data/test/ruby_lsp_refactor/variable_listener_test.rb +97 -0
- metadata +143 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Handles all conditional-related code actions by listening to IfNode and
|
|
8
|
+
# UnlessNode events from the Prism dispatcher.
|
|
9
|
+
#
|
|
10
|
+
# Emitted actions
|
|
11
|
+
# ───────────────
|
|
12
|
+
# 1. Convert to post-conditional
|
|
13
|
+
# if cond → body if cond
|
|
14
|
+
# body
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# 2. Convert to block if / block unless
|
|
18
|
+
# body if cond → if cond
|
|
19
|
+
# body
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# 3. Toggle if ↔ unless
|
|
23
|
+
# if cond → unless cond (and vice-versa, no else/elsif)
|
|
24
|
+
#
|
|
25
|
+
# 4. Invert if/else
|
|
26
|
+
# if cond → if !cond
|
|
27
|
+
# then_body else_body
|
|
28
|
+
# else → else
|
|
29
|
+
# else_body then_body
|
|
30
|
+
# end → end
|
|
31
|
+
class ConditionalListener
|
|
32
|
+
include RubyLsp::Requests::Support::Common
|
|
33
|
+
include Support::NodeHelpers
|
|
34
|
+
|
|
35
|
+
# @param response_builder [RubyLsp::ResponseBuilders::CollectionResponseBuilder]
|
|
36
|
+
# @param node_context [RubyLsp::NodeContext]
|
|
37
|
+
# @param dispatcher [Prism::Dispatcher]
|
|
38
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
39
|
+
@response_builder = response_builder
|
|
40
|
+
@node_context = node_context
|
|
41
|
+
|
|
42
|
+
dispatcher.register(self, :on_if_node_enter, :on_unless_node_enter)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── dispatcher callbacks ────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def on_if_node_enter(node)
|
|
48
|
+
return unless node_covers_cursor?(node)
|
|
49
|
+
|
|
50
|
+
emit_to_post_conditional(node) if block_if_convertible_to_post?(node)
|
|
51
|
+
emit_to_block_if(node) if post_if_convertible_to_block?(node)
|
|
52
|
+
emit_toggle_to_unless(node) if toggleable_if?(node)
|
|
53
|
+
emit_invert_if_else(node) if invertible_if_else?(node)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def on_unless_node_enter(node)
|
|
59
|
+
return unless node_covers_cursor?(node)
|
|
60
|
+
|
|
61
|
+
emit_to_post_unless(node) if block_unless_convertible_to_post?(node)
|
|
62
|
+
emit_to_block_unless(node) if post_unless_convertible_to_block?(node)
|
|
63
|
+
emit_toggle_to_if(node) if toggleable_unless?(node)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# ── predicate helpers ───────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
# block `if` with a single-statement body and no else/elsif
|
|
73
|
+
def block_if_convertible_to_post?(node)
|
|
74
|
+
node.end_keyword_loc &&
|
|
75
|
+
node.subsequent.nil? &&
|
|
76
|
+
node.statements&.body&.length == 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# post-conditional `if` (no `end` keyword)
|
|
80
|
+
def post_if_convertible_to_block?(node)
|
|
81
|
+
node.end_keyword_loc.nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# block `if` with no else/elsif — can flip to `unless`
|
|
85
|
+
def toggleable_if?(node)
|
|
86
|
+
node.end_keyword_loc && node.subsequent.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# block `if` with exactly one else branch (no elsif) — can invert
|
|
90
|
+
def invertible_if_else?(node)
|
|
91
|
+
node.end_keyword_loc &&
|
|
92
|
+
node.subsequent.is_a?(Prism::ElseNode) &&
|
|
93
|
+
node.statements&.body&.length&.positive? &&
|
|
94
|
+
node.subsequent.statements&.body&.length&.positive?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# block `unless` with a single-statement body and no else
|
|
98
|
+
def block_unless_convertible_to_post?(node)
|
|
99
|
+
node.end_keyword_loc &&
|
|
100
|
+
unless_else_clause(node).nil? &&
|
|
101
|
+
node.statements&.body&.length == 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# post-conditional `unless` (no `end` keyword)
|
|
105
|
+
def post_unless_convertible_to_block?(node)
|
|
106
|
+
node.end_keyword_loc.nil?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# block `unless` with no else — can flip to `if`
|
|
110
|
+
def toggleable_unless?(node)
|
|
111
|
+
node.end_keyword_loc && unless_else_clause(node).nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# `UnlessNode#consequent` is deprecated in Prism >= 1.x; use `else_clause`.
|
|
115
|
+
def unless_else_clause(node)
|
|
116
|
+
node.respond_to?(:else_clause) ? node.else_clause : node.consequent
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ── emitters ────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
# 1a. block if → post-conditional if
|
|
122
|
+
def emit_to_post_conditional(node)
|
|
123
|
+
indent = indent_for(node)
|
|
124
|
+
cond_src = node.predicate.location.slice.strip
|
|
125
|
+
body_src = node.statements.body.first.location.slice.strip
|
|
126
|
+
new_text = "#{indent}#{body_src} if #{cond_src}"
|
|
127
|
+
|
|
128
|
+
@response_builder << Interface::CodeAction.new(
|
|
129
|
+
title: "Convert to post-conditional",
|
|
130
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
131
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# 1b. block unless → post-conditional unless
|
|
136
|
+
def emit_to_post_unless(node)
|
|
137
|
+
indent = indent_for(node)
|
|
138
|
+
cond_src = node.predicate.location.slice.strip
|
|
139
|
+
body_src = node.statements.body.first.location.slice.strip
|
|
140
|
+
new_text = "#{indent}#{body_src} unless #{cond_src}"
|
|
141
|
+
|
|
142
|
+
@response_builder << Interface::CodeAction.new(
|
|
143
|
+
title: "Convert to post-conditional",
|
|
144
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
145
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# 2a. post-conditional if → block if
|
|
150
|
+
def emit_to_block_if(node)
|
|
151
|
+
indent = indent_for(node)
|
|
152
|
+
cond_src = node.predicate.location.slice.strip
|
|
153
|
+
body_src = node.statements.body.first.location.slice.strip
|
|
154
|
+
new_text = "#{indent}if #{cond_src}\n#{indent} #{body_src}\n#{indent}end"
|
|
155
|
+
|
|
156
|
+
@response_builder << Interface::CodeAction.new(
|
|
157
|
+
title: "Convert to block if",
|
|
158
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
159
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# 2b. post-conditional unless → block unless
|
|
164
|
+
def emit_to_block_unless(node)
|
|
165
|
+
indent = indent_for(node)
|
|
166
|
+
cond_src = node.predicate.location.slice.strip
|
|
167
|
+
body_src = node.statements.body.first.location.slice.strip
|
|
168
|
+
new_text = "#{indent}unless #{cond_src}\n#{indent} #{body_src}\n#{indent}end"
|
|
169
|
+
|
|
170
|
+
@response_builder << Interface::CodeAction.new(
|
|
171
|
+
title: "Convert to block unless",
|
|
172
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
173
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# 3a. if → unless (strips leading `!` from predicate when present)
|
|
178
|
+
def emit_toggle_to_unless(node)
|
|
179
|
+
indent = indent_for(node)
|
|
180
|
+
cond_src = stripped_negation(node.predicate)
|
|
181
|
+
body_src = node.statements.body.map { |s| "#{indent} #{s.location.slice.strip}" }.join("\n")
|
|
182
|
+
new_text = "#{indent}unless #{cond_src}\n#{body_src}\n#{indent}end"
|
|
183
|
+
|
|
184
|
+
@response_builder << Interface::CodeAction.new(
|
|
185
|
+
title: "Convert to unless",
|
|
186
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
187
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 3b. unless → if (strips leading `!` from predicate when present)
|
|
192
|
+
def emit_toggle_to_if(node)
|
|
193
|
+
indent = indent_for(node)
|
|
194
|
+
cond_src = stripped_negation(node.predicate)
|
|
195
|
+
body_src = node.statements.body.map { |s| "#{indent} #{s.location.slice.strip}" }.join("\n")
|
|
196
|
+
new_text = "#{indent}if #{cond_src}\n#{body_src}\n#{indent}end"
|
|
197
|
+
|
|
198
|
+
@response_builder << Interface::CodeAction.new(
|
|
199
|
+
title: "Convert to if",
|
|
200
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
201
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# 4. Invert if/else — swap branches, negate predicate
|
|
206
|
+
def emit_invert_if_else(node)
|
|
207
|
+
indent = indent_for(node)
|
|
208
|
+
cond_src = negated_predicate(node.predicate)
|
|
209
|
+
then_body = node.statements.body.map { |s| "#{indent} #{s.location.slice.strip}" }.join("\n")
|
|
210
|
+
else_body = node.subsequent.statements.body.map { |s| "#{indent} #{s.location.slice.strip}" }.join("\n")
|
|
211
|
+
new_text = "#{indent}if #{cond_src}\n#{else_body}\n#{indent}else\n#{then_body}\n#{indent}end"
|
|
212
|
+
|
|
213
|
+
@response_builder << Interface::CodeAction.new(
|
|
214
|
+
title: "Invert if/else",
|
|
215
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
216
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── predicate negation helpers ──────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
# Returns the source of the predicate with a leading `!` stripped.
|
|
223
|
+
# If the predicate is not a `!` call, returns it unchanged (used for
|
|
224
|
+
# toggle: `if !x` → `unless x`, `unless !x` → `if x`).
|
|
225
|
+
def stripped_negation(predicate)
|
|
226
|
+
if bang_call?(predicate)
|
|
227
|
+
predicate.receiver.location.slice.strip
|
|
228
|
+
else
|
|
229
|
+
predicate.location.slice.strip
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns a negated form of the predicate source:
|
|
234
|
+
# - `!x` → `x` (double-negation cancels)
|
|
235
|
+
# - `x` → `!x`
|
|
236
|
+
def negated_predicate(predicate)
|
|
237
|
+
if bang_call?(predicate)
|
|
238
|
+
predicate.receiver.location.slice.strip
|
|
239
|
+
else
|
|
240
|
+
"!#{predicate.location.slice.strip}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Returns true when +node+ is a `!` unary call (CallNode with name :!).
|
|
245
|
+
def bang_call?(node)
|
|
246
|
+
node.is_a?(Prism::CallNode) && node.name == :! && node.receiver
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Emits a "Convert to keyword syntax" code action when the cursor is on a
|
|
8
|
+
# HashNode (or a keyword-argument hash) that contains at least one
|
|
9
|
+
# hash-rocket pair whose key is a simple symbol.
|
|
10
|
+
#
|
|
11
|
+
# Input: { :foo => 1, :bar => "x" }
|
|
12
|
+
# Output: { foo: 1, bar: "x" }
|
|
13
|
+
#
|
|
14
|
+
# Pairs whose key is NOT a plain symbol (e.g. string keys, computed keys,
|
|
15
|
+
# or keys that are already in keyword syntax) are left unchanged.
|
|
16
|
+
class HashListener
|
|
17
|
+
include RubyLsp::Requests::Support::Common
|
|
18
|
+
include Support::NodeHelpers
|
|
19
|
+
|
|
20
|
+
# @param response_builder [RubyLsp::ResponseBuilders::CollectionResponseBuilder]
|
|
21
|
+
# @param node_context [RubyLsp::NodeContext]
|
|
22
|
+
# @param dispatcher [Prism::Dispatcher]
|
|
23
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
24
|
+
@response_builder = response_builder
|
|
25
|
+
@node_context = node_context
|
|
26
|
+
|
|
27
|
+
dispatcher.register(self, :on_hash_node_enter, :on_keyword_hash_node_enter)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_hash_node_enter(node)
|
|
31
|
+
return unless node_covers_cursor?(node)
|
|
32
|
+
return unless has_rocket_pairs?(node.elements)
|
|
33
|
+
|
|
34
|
+
emit_convert_hash(node, node.elements)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# keyword_hash_node appears in method call arguments: foo(:a => 1)
|
|
40
|
+
def on_keyword_hash_node_enter(node)
|
|
41
|
+
return unless node_covers_cursor?(node)
|
|
42
|
+
return unless has_rocket_pairs?(node.elements)
|
|
43
|
+
|
|
44
|
+
emit_convert_hash(node, node.elements)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Returns true when at least one AssocNode uses a hash-rocket operator
|
|
52
|
+
# and has a plain SymbolNode key.
|
|
53
|
+
def has_rocket_pairs?(elements)
|
|
54
|
+
elements.any? { |el| rocket_assoc?(el) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An AssocNode is a rocket pair when it has an operator_loc (the `=>`).
|
|
58
|
+
def rocket_assoc?(el)
|
|
59
|
+
el.is_a?(Prism::AssocNode) &&
|
|
60
|
+
el.operator_loc &&
|
|
61
|
+
el.key.is_a?(Prism::SymbolNode) &&
|
|
62
|
+
el.key.opening_loc&.slice == ":"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def emit_convert_hash(node, elements)
|
|
66
|
+
new_pairs = elements.map { |el| convert_element(el) }
|
|
67
|
+
|
|
68
|
+
# Reconstruct the hash preserving the outer braces when present.
|
|
69
|
+
# HashNode has opening/closing braces; KeywordHashNode does not.
|
|
70
|
+
if node.is_a?(Prism::HashNode)
|
|
71
|
+
new_text = "{ #{new_pairs.join(", ")} }"
|
|
72
|
+
else
|
|
73
|
+
new_text = new_pairs.join(", ")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@response_builder << Interface::CodeAction.new(
|
|
77
|
+
title: "Convert to keyword syntax",
|
|
78
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
79
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Converts a single hash element to its string representation.
|
|
84
|
+
# Rocket pairs with symbol keys become `key: value`; everything else
|
|
85
|
+
# is reproduced verbatim from the source.
|
|
86
|
+
def convert_element(el)
|
|
87
|
+
if rocket_assoc?(el)
|
|
88
|
+
key_name = el.key.unescaped
|
|
89
|
+
value_src = el.value.location.slice.strip
|
|
90
|
+
"#{key_name}: #{value_src}"
|
|
91
|
+
else
|
|
92
|
+
el.location.slice.strip
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Handles method-level structural refactors.
|
|
8
|
+
#
|
|
9
|
+
# Emitted actions
|
|
10
|
+
# ───────────────
|
|
11
|
+
# 1. Extract to method
|
|
12
|
+
# Cursor on a LocalVariableWriteNode inside a def body.
|
|
13
|
+
# Extracts the assignment RHS (and the assignment itself) into a new
|
|
14
|
+
# private method, passing any variables that are defined before the
|
|
15
|
+
# extraction point and referenced inside the extracted expression as
|
|
16
|
+
# parameters.
|
|
17
|
+
#
|
|
18
|
+
# 2. Add parameter
|
|
19
|
+
# Cursor anywhere inside a DefNode.
|
|
20
|
+
# Appends a `new_param` placeholder at the end of the parameter list
|
|
21
|
+
# (or creates parentheses if the method has none).
|
|
22
|
+
#
|
|
23
|
+
# 3. Convert to keyword arguments
|
|
24
|
+
# Cursor anywhere inside a DefNode that has required positional params.
|
|
25
|
+
# Rewrites `def foo(a, b)` → `def foo(a:, b:)` and updates every
|
|
26
|
+
# call-site within the same file that passes positional arguments.
|
|
27
|
+
#
|
|
28
|
+
# 4. Extract to let (RSpec)
|
|
29
|
+
# Cursor on a LocalVariableWriteNode inside an RSpec `it`/`specify`
|
|
30
|
+
# block. Moves the assignment into a `let(:name) { value }` block
|
|
31
|
+
# inserted above the enclosing example group call.
|
|
32
|
+
class MethodListener
|
|
33
|
+
include RubyLsp::Requests::Support::Common
|
|
34
|
+
include Support::NodeHelpers
|
|
35
|
+
|
|
36
|
+
# @param response_builder [RubyLsp::ResponseBuilders::CollectionResponseBuilder]
|
|
37
|
+
# @param node_context [RubyLsp::NodeContext]
|
|
38
|
+
# @param dispatcher [Prism::Dispatcher]
|
|
39
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
40
|
+
@response_builder = response_builder
|
|
41
|
+
@node_context = node_context
|
|
42
|
+
|
|
43
|
+
# Accumulate ancestor context during the single-pass walk.
|
|
44
|
+
# Each entry is a Hash with :type and the node itself.
|
|
45
|
+
@ancestor_stack = []
|
|
46
|
+
|
|
47
|
+
# All write nodes seen so far (for "defined before extraction point").
|
|
48
|
+
@seen_writes = []
|
|
49
|
+
|
|
50
|
+
dispatcher.register(
|
|
51
|
+
self,
|
|
52
|
+
:on_def_node_enter,
|
|
53
|
+
:on_def_node_leave,
|
|
54
|
+
:on_call_node_enter,
|
|
55
|
+
:on_call_node_leave,
|
|
56
|
+
:on_local_variable_write_node_enter,
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ── dispatcher callbacks ────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
# Push def onto the ancestor stack; also emit parameter actions when the
|
|
63
|
+
# cursor is inside this def.
|
|
64
|
+
def on_def_node_enter(node)
|
|
65
|
+
@ancestor_stack.push({ type: :def, node: node })
|
|
66
|
+
|
|
67
|
+
return unless node_covers_cursor?(node)
|
|
68
|
+
|
|
69
|
+
emit_add_parameter(node)
|
|
70
|
+
emit_convert_to_kwargs(node) if has_positional_params?(node)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def on_def_node_leave(_node)
|
|
76
|
+
@ancestor_stack.pop
|
|
77
|
+
@seen_writes.clear
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def on_call_node_enter(node)
|
|
83
|
+
@ancestor_stack.push({ type: :call, node: node }) if rspec_example?(node)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def on_call_node_leave(node)
|
|
89
|
+
@ancestor_stack.pop if rspec_example?(node)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_local_variable_write_node_enter(node)
|
|
95
|
+
enclosing_def = nearest_ancestor(:def)
|
|
96
|
+
enclosing_call = nearest_ancestor(:call)
|
|
97
|
+
|
|
98
|
+
# Always track writes for param-detection, regardless of cursor position.
|
|
99
|
+
@seen_writes << node
|
|
100
|
+
|
|
101
|
+
return unless node_covers_cursor?(node)
|
|
102
|
+
|
|
103
|
+
if enclosing_call && rspec_example?(enclosing_call[:node])
|
|
104
|
+
emit_extract_to_let(node, enclosing_call[:node])
|
|
105
|
+
elsif enclosing_def
|
|
106
|
+
emit_extract_to_method(node, enclosing_def[:node])
|
|
107
|
+
end
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# ── ancestor helpers ────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def nearest_ancestor(type)
|
|
117
|
+
@ancestor_stack.reverse.find { |a| a[:type] == type }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ── 1. Extract to method ─────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def emit_extract_to_method(write_node, def_node)
|
|
123
|
+
method_name = write_node.name.to_s
|
|
124
|
+
rhs_src = write_node.value.location.slice.strip
|
|
125
|
+
indent = indent_for(def_node)
|
|
126
|
+
body_indent = "#{indent} "
|
|
127
|
+
|
|
128
|
+
# Determine which variables defined before this write are referenced
|
|
129
|
+
# inside the RHS expression.
|
|
130
|
+
params = params_needed_for(write_node.value, def_node)
|
|
131
|
+
param_list = params.empty? ? "" : "(#{params.join(", ")})"
|
|
132
|
+
call_args = params.empty? ? "" : "(#{params.join(", ")})"
|
|
133
|
+
|
|
134
|
+
# Replace the assignment RHS with a call to the new method.
|
|
135
|
+
replace_edit = Interface::TextEdit.new(
|
|
136
|
+
range: node_to_lsp_range(write_node.value),
|
|
137
|
+
new_text: "#{method_name}#{call_args}",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Insert the new private method after the enclosing def's closing `end`.
|
|
141
|
+
insert_line = def_node.location.end_line # 1-based; insert after this line
|
|
142
|
+
new_method = "\n#{body_indent}private\n\n" \
|
|
143
|
+
"#{body_indent}def #{method_name}#{param_list}\n" \
|
|
144
|
+
"#{body_indent} #{rhs_src}\n" \
|
|
145
|
+
"#{body_indent}end\n"
|
|
146
|
+
|
|
147
|
+
insert_edit = Interface::TextEdit.new(
|
|
148
|
+
range: Interface::Range.new(
|
|
149
|
+
start: Interface::Position.new(line: insert_line, character: 0),
|
|
150
|
+
end: Interface::Position.new(line: insert_line, character: 0),
|
|
151
|
+
),
|
|
152
|
+
new_text: new_method,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@response_builder << Interface::CodeAction.new(
|
|
156
|
+
title: "Extract to method '#{method_name}'",
|
|
157
|
+
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
158
|
+
edit: multi_edit_workspace_edit([replace_edit, insert_edit]),
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Collect names of variables that are:
|
|
163
|
+
# a) written before the extraction point in the same def body, AND
|
|
164
|
+
# b) referenced (read) inside +expr_node+.
|
|
165
|
+
def params_needed_for(expr_node, def_node)
|
|
166
|
+
expr_src = expr_node.location.slice
|
|
167
|
+
|
|
168
|
+
@seen_writes
|
|
169
|
+
.select { |w| w.location.start_offset < expr_node.location.start_offset }
|
|
170
|
+
.map { |w| w.name.to_s }
|
|
171
|
+
.select { |name| expr_src.match?(/\b#{Regexp.escape(name)}\b/) }
|
|
172
|
+
.uniq
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ── 2. Add parameter ─────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def emit_add_parameter(def_node)
|
|
178
|
+
if def_node.parameters
|
|
179
|
+
# Append after the last existing parameter.
|
|
180
|
+
last_param = last_parameter(def_node.parameters)
|
|
181
|
+
insert_col = last_param.location.end_column
|
|
182
|
+
insert_line = last_param.location.end_line - 1
|
|
183
|
+
new_text_fragment = ", new_param"
|
|
184
|
+
|
|
185
|
+
edit = Interface::TextEdit.new(
|
|
186
|
+
range: Interface::Range.new(
|
|
187
|
+
start: Interface::Position.new(line: insert_line, character: insert_col),
|
|
188
|
+
end: Interface::Position.new(line: insert_line, character: insert_col),
|
|
189
|
+
),
|
|
190
|
+
new_text: new_text_fragment,
|
|
191
|
+
)
|
|
192
|
+
else
|
|
193
|
+
# No parameters yet — insert `(new_param)` right after the method name.
|
|
194
|
+
name_end_col = def_node.name_loc.end_column
|
|
195
|
+
name_end_line = def_node.name_loc.end_line - 1
|
|
196
|
+
|
|
197
|
+
edit = Interface::TextEdit.new(
|
|
198
|
+
range: Interface::Range.new(
|
|
199
|
+
start: Interface::Position.new(line: name_end_line, character: name_end_col),
|
|
200
|
+
end: Interface::Position.new(line: name_end_line, character: name_end_col),
|
|
201
|
+
),
|
|
202
|
+
new_text: "(new_param)",
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
@response_builder << Interface::CodeAction.new(
|
|
207
|
+
title: "Add parameter",
|
|
208
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
209
|
+
edit: multi_edit_workspace_edit([edit]),
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Returns the last leaf parameter node from a ParametersNode.
|
|
214
|
+
def last_parameter(params_node)
|
|
215
|
+
[
|
|
216
|
+
params_node.requireds,
|
|
217
|
+
params_node.optionals,
|
|
218
|
+
params_node.keywords,
|
|
219
|
+
[params_node.rest, params_node.keyword_rest, params_node.block].compact,
|
|
220
|
+
].flatten.compact.last
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ── 3. Convert to keyword arguments ──────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
def has_positional_params?(def_node)
|
|
226
|
+
def_node.parameters&.requireds&.any? { |p| p.is_a?(Prism::RequiredParameterNode) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def emit_convert_to_kwargs(def_node)
|
|
230
|
+
params_node = def_node.parameters
|
|
231
|
+
requireds = params_node.requireds.select { |p| p.is_a?(Prism::RequiredParameterNode) }
|
|
232
|
+
|
|
233
|
+
# Build the new parameter list: keep non-required params verbatim,
|
|
234
|
+
# convert required positionals to `name:`.
|
|
235
|
+
all_params = build_kwargs_param_list(params_node, requireds)
|
|
236
|
+
new_params = all_params.join(", ")
|
|
237
|
+
|
|
238
|
+
# Replace the entire parameters span (between the parens).
|
|
239
|
+
params_range = Interface::Range.new(
|
|
240
|
+
start: Interface::Position.new(
|
|
241
|
+
line: params_node.location.start_line - 1,
|
|
242
|
+
character: params_node.location.start_column,
|
|
243
|
+
),
|
|
244
|
+
end: Interface::Position.new(
|
|
245
|
+
line: params_node.location.end_line - 1,
|
|
246
|
+
character: params_node.location.end_column,
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
edit = Interface::TextEdit.new(range: params_range, new_text: new_params)
|
|
251
|
+
|
|
252
|
+
@response_builder << Interface::CodeAction.new(
|
|
253
|
+
title: "Convert to keyword arguments",
|
|
254
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
255
|
+
edit: multi_edit_workspace_edit([edit]),
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def build_kwargs_param_list(params_node, requireds)
|
|
260
|
+
required_names = requireds.map(&:name)
|
|
261
|
+
parts = []
|
|
262
|
+
|
|
263
|
+
params_node.requireds.each do |p|
|
|
264
|
+
if required_names.include?(p.name)
|
|
265
|
+
parts << "#{p.name}:"
|
|
266
|
+
else
|
|
267
|
+
parts << p.location.slice.strip
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
params_node.optionals.each { |p| parts << p.location.slice.strip }
|
|
272
|
+
parts << params_node.rest.location.slice.strip if params_node.rest
|
|
273
|
+
params_node.keywords.each { |p| parts << p.location.slice.strip }
|
|
274
|
+
parts << params_node.keyword_rest.location.slice.strip if params_node.keyword_rest
|
|
275
|
+
parts << params_node.block.location.slice.strip if params_node.block
|
|
276
|
+
|
|
277
|
+
parts
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# ── 4. Extract to let (RSpec) ─────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
RSPEC_EXAMPLE_METHODS = %i[it specify example scenario].freeze
|
|
283
|
+
|
|
284
|
+
def rspec_example?(call_node)
|
|
285
|
+
RSPEC_EXAMPLE_METHODS.include?(call_node.name) && call_node.block
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def emit_extract_to_let(write_node, example_call_node)
|
|
289
|
+
var_name = write_node.name.to_s
|
|
290
|
+
rhs_src = write_node.value.location.slice.strip
|
|
291
|
+
indent = indent_for(example_call_node)
|
|
292
|
+
|
|
293
|
+
# Insert `let(:name) { value }` on the line before the example call.
|
|
294
|
+
insert_line = example_call_node.location.start_line - 1
|
|
295
|
+
let_text = "#{indent}let(:#{var_name}) { #{rhs_src} }\n"
|
|
296
|
+
|
|
297
|
+
insert_edit = Interface::TextEdit.new(
|
|
298
|
+
range: Interface::Range.new(
|
|
299
|
+
start: Interface::Position.new(line: insert_line, character: 0),
|
|
300
|
+
end: Interface::Position.new(line: insert_line, character: 0),
|
|
301
|
+
),
|
|
302
|
+
new_text: let_text,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Delete the original assignment line inside the example.
|
|
306
|
+
delete_edit = delete_line_edit(write_node)
|
|
307
|
+
|
|
308
|
+
@response_builder << Interface::CodeAction.new(
|
|
309
|
+
title: "Extract to let(:#{var_name})",
|
|
310
|
+
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
311
|
+
edit: multi_edit_workspace_edit([insert_edit, delete_edit]),
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|