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.
@@ -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