ruby-lsp-refactor 0.1.0 → 0.1.1

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +68 -0
  3. data/lib/ruby/lsp/refactor/version.rb +1 -1
  4. data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +35 -9
  5. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/accessor_listener.rb +156 -0
  6. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +2 -2
  7. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/block_style_listener.rb +117 -0
  8. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/conditional_listener.rb +14 -14
  9. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/constant_listener.rb +118 -0
  10. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/enumerable_listener.rb +90 -0
  11. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +7 -7
  12. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/logical_operator_listener.rb +70 -0
  13. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +34 -34
  14. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/raise_listener.rb +70 -0
  15. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rescue_listener.rb +85 -0
  16. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rspec_let_listener.rb +61 -0
  17. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_array_listener.rb +88 -0
  18. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_freeze_listener.rb +86 -0
  19. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +2 -2
  20. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/super_listener.rb +95 -0
  21. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +11 -11
  22. data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +12 -12
  23. data/lib/ruby_lsp/test_helper.rb +5 -5
  24. data/test/ruby_lsp_refactor/accessor_listener_test.rb +91 -0
  25. data/test/ruby_lsp_refactor/block_style_listener_test.rb +98 -0
  26. data/test/ruby_lsp_refactor/constant_listener_test.rb +68 -0
  27. data/test/ruby_lsp_refactor/enumerable_listener_test.rb +80 -0
  28. data/test/ruby_lsp_refactor/logical_operator_listener_test.rb +66 -0
  29. data/test/ruby_lsp_refactor/raise_listener_test.rb +64 -0
  30. data/test/ruby_lsp_refactor/rescue_listener_test.rb +72 -0
  31. data/test/ruby_lsp_refactor/rspec_let_listener_test.rb +54 -0
  32. data/test/ruby_lsp_refactor/string_array_listener_test.rb +64 -0
  33. data/test/ruby_lsp_refactor/string_freeze_listener_test.rb +52 -0
  34. data/test/ruby_lsp_refactor/string_listener_test.rb +2 -2
  35. data/test/ruby_lsp_refactor/super_listener_test.rb +65 -0
  36. metadata +36 -13
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Detects common Enumerable method-chain patterns and offers a single
8
+ # collapsed replacement.
9
+ #
10
+ # Emitted actions
11
+ # ───────────────
12
+ # 1. map + flatten(1) / flatten → flat_map
13
+ # items.map { |i| i.tags }.flatten(1) → items.flat_map { |i| i.tags }
14
+ #
15
+ # 2. select + first → find
16
+ # users.select { |u| u.admin? }.first → users.find { |u| u.admin? }
17
+ #
18
+ # 3. map + compact → filter_map
19
+ # items.map { |i| i.value }.compact → items.filter_map { |i| i.value }
20
+ #
21
+ # All three patterns share the same structure:
22
+ # outer_call( receiver: inner_call( block: BlockNode ) )
23
+ # where outer_call has no block of its own.
24
+ class EnumerableListener
25
+ include RubyLsp::Requests::Support::Common
26
+ include Support::NodeHelpers
27
+
28
+ # { outer_method => { inner_method => replacement } }
29
+ PATTERNS = {
30
+ flatten: { map: "flat_map" },
31
+ first: { select: "find" },
32
+ compact: { map: "filter_map" }
33
+ }.freeze
34
+
35
+ def initialize(response_builder, node_context, dispatcher)
36
+ @response_builder = response_builder
37
+ @node_context = node_context
38
+
39
+ dispatcher.register(self, :on_call_node_enter)
40
+ end
41
+
42
+ def on_call_node_enter(node)
43
+ return unless node_covers_cursor?(node)
44
+ return if node.block # outer call must not have its own block
45
+
46
+ outer_name = node.name
47
+ return unless PATTERNS.key?(outer_name)
48
+
49
+ # flatten is only eligible when called with no args or with arg `1`.
50
+ return if outer_name == :flatten && !flatten_eligible?(node)
51
+
52
+ inner = node.receiver
53
+ return unless inner.is_a?(Prism::CallNode) && inner.block.is_a?(Prism::BlockNode)
54
+
55
+ replacement = PATTERNS[outer_name][inner.name]
56
+ return unless replacement
57
+
58
+ emit_collapse(node, inner, replacement)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ private
64
+
65
+ # flatten() and flatten(1) are eligible; flatten(2+) changes semantics.
66
+ def flatten_eligible?(node)
67
+ args = node.arguments&.arguments
68
+ return true if args.nil? || args.empty?
69
+ return true if args.length == 1 && args.first.is_a?(Prism::IntegerNode) &&
70
+ args.first.location.slice == "1"
71
+
72
+ false
73
+ end
74
+
75
+ def emit_collapse(outer_node, inner_node, replacement)
76
+ # Reconstruct: <receiver>.<replacement> <block>
77
+ receiver_src = inner_node.receiver.location.slice.strip
78
+ block_src = inner_node.block.location.slice.strip
79
+
80
+ new_text = "#{indent_for(outer_node)}#{receiver_src}.#{replacement} #{block_src}"
81
+
82
+ @response_builder << Interface::CodeAction.new(
83
+ title: "Convert to .#{replacement}",
84
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
85
+ edit: single_edit_workspace_edit(outer_node, new_text)
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -67,16 +67,16 @@ module RubyLsp
67
67
 
68
68
  # Reconstruct the hash preserving the outer braces when present.
69
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
70
+ new_text = if node.is_a?(Prism::HashNode)
71
+ "{ #{new_pairs.join(", ")} }"
72
+ else
73
+ new_pairs.join(", ")
74
+ end
75
75
 
76
76
  @response_builder << Interface::CodeAction.new(
77
77
  title: "Convert to keyword syntax",
78
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
79
- edit: single_edit_workspace_edit(node, new_text),
78
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
79
+ edit: single_edit_workspace_edit(node, new_text)
80
80
  )
81
81
  end
82
82
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Toggles between symbolic and word forms of logical operators.
8
+ #
9
+ # Emitted actions
10
+ # ───────────────
11
+ # AndNode:
12
+ # user.valid? && user.save → user.valid? and user.save
13
+ # user.valid? and user.save → user.valid? && user.save
14
+ #
15
+ # OrNode:
16
+ # a || b → a or b
17
+ # a or b → a || b
18
+ class LogicalOperatorListener
19
+ include RubyLsp::Requests::Support::Common
20
+ include Support::NodeHelpers
21
+
22
+ def initialize(response_builder, node_context, dispatcher)
23
+ @response_builder = response_builder
24
+ @node_context = node_context
25
+
26
+ dispatcher.register(self, :on_and_node_enter, :on_or_node_enter)
27
+ end
28
+
29
+ def on_and_node_enter(node)
30
+ return unless node_covers_cursor?(node)
31
+
32
+ if node.operator_loc.slice == "&&"
33
+ emit_toggle(node, "&&", "and")
34
+ else
35
+ emit_toggle(node, "and", "&&")
36
+ end
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def on_or_node_enter(node)
42
+ return unless node_covers_cursor?(node)
43
+
44
+ if node.operator_loc.slice == "||"
45
+ emit_toggle(node, "||", "or")
46
+ else
47
+ emit_toggle(node, "or", "||")
48
+ end
49
+ rescue StandardError
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def emit_toggle(node, from_op, to_op)
56
+ left_src = node.left.location.slice.strip
57
+ right_src = node.right.location.slice.strip
58
+ new_text = "#{indent_for(node)}#{left_src} #{to_op} #{right_src}"
59
+
60
+ title = "Convert '#{from_op}' to '#{to_op}'"
61
+
62
+ @response_builder << Interface::CodeAction.new(
63
+ title: title,
64
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
65
+ edit: single_edit_workspace_edit(node, new_text)
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -53,7 +53,7 @@ module RubyLsp
53
53
  :on_def_node_leave,
54
54
  :on_call_node_enter,
55
55
  :on_call_node_leave,
56
- :on_local_variable_write_node_enter,
56
+ :on_local_variable_write_node_enter
57
57
  )
58
58
  end
59
59
 
@@ -133,12 +133,12 @@ module RubyLsp
133
133
 
134
134
  # Replace the assignment RHS with a call to the new method.
135
135
  replace_edit = Interface::TextEdit.new(
136
- range: node_to_lsp_range(write_node.value),
137
- new_text: "#{method_name}#{call_args}",
136
+ range: node_to_lsp_range(write_node.value),
137
+ new_text: "#{method_name}#{call_args}"
138
138
  )
139
139
 
140
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
141
+ insert_line = def_node.location.end_line # 1-based; insert after this line
142
142
  new_method = "\n#{body_indent}private\n\n" \
143
143
  "#{body_indent}def #{method_name}#{param_list}\n" \
144
144
  "#{body_indent} #{rhs_src}\n" \
@@ -147,22 +147,22 @@ module RubyLsp
147
147
  insert_edit = Interface::TextEdit.new(
148
148
  range: Interface::Range.new(
149
149
  start: Interface::Position.new(line: insert_line, character: 0),
150
- end: Interface::Position.new(line: insert_line, character: 0),
150
+ end: Interface::Position.new(line: insert_line, character: 0)
151
151
  ),
152
- new_text: new_method,
152
+ new_text: new_method
153
153
  )
154
154
 
155
155
  @response_builder << Interface::CodeAction.new(
156
156
  title: "Extract to method '#{method_name}'",
157
- kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
158
- edit: multi_edit_workspace_edit([replace_edit, insert_edit]),
157
+ kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
158
+ edit: multi_edit_workspace_edit([replace_edit, insert_edit])
159
159
  )
160
160
  end
161
161
 
162
162
  # Collect names of variables that are:
163
163
  # a) written before the extraction point in the same def body, AND
164
164
  # b) referenced (read) inside +expr_node+.
165
- def params_needed_for(expr_node, def_node)
165
+ def params_needed_for(expr_node, _def_node)
166
166
  expr_src = expr_node.location.slice
167
167
 
168
168
  @seen_writes
@@ -185,9 +185,9 @@ module RubyLsp
185
185
  edit = Interface::TextEdit.new(
186
186
  range: Interface::Range.new(
187
187
  start: Interface::Position.new(line: insert_line, character: insert_col),
188
- end: Interface::Position.new(line: insert_line, character: insert_col),
188
+ end: Interface::Position.new(line: insert_line, character: insert_col)
189
189
  ),
190
- new_text: new_text_fragment,
190
+ new_text: new_text_fragment
191
191
  )
192
192
  else
193
193
  # No parameters yet — insert `(new_param)` right after the method name.
@@ -197,16 +197,16 @@ module RubyLsp
197
197
  edit = Interface::TextEdit.new(
198
198
  range: Interface::Range.new(
199
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),
200
+ end: Interface::Position.new(line: name_end_line, character: name_end_col)
201
201
  ),
202
- new_text: "(new_param)",
202
+ new_text: "(new_param)"
203
203
  )
204
204
  end
205
205
 
206
206
  @response_builder << Interface::CodeAction.new(
207
207
  title: "Add parameter",
208
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
209
- edit: multi_edit_workspace_edit([edit]),
208
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
209
+ edit: multi_edit_workspace_edit([edit])
210
210
  )
211
211
  end
212
212
 
@@ -216,7 +216,7 @@ module RubyLsp
216
216
  params_node.requireds,
217
217
  params_node.optionals,
218
218
  params_node.keywords,
219
- [params_node.rest, params_node.keyword_rest, params_node.block].compact,
219
+ [params_node.rest, params_node.keyword_rest, params_node.block].compact
220
220
  ].flatten.compact.last
221
221
  end
222
222
 
@@ -238,21 +238,21 @@ module RubyLsp
238
238
  # Replace the entire parameters span (between the parens).
239
239
  params_range = Interface::Range.new(
240
240
  start: Interface::Position.new(
241
- line: params_node.location.start_line - 1,
242
- character: params_node.location.start_column,
241
+ line: params_node.location.start_line - 1,
242
+ character: params_node.location.start_column
243
243
  ),
244
244
  end: Interface::Position.new(
245
- line: params_node.location.end_line - 1,
246
- character: params_node.location.end_column,
247
- ),
245
+ line: params_node.location.end_line - 1,
246
+ character: params_node.location.end_column
247
+ )
248
248
  )
249
249
 
250
250
  edit = Interface::TextEdit.new(range: params_range, new_text: new_params)
251
251
 
252
252
  @response_builder << Interface::CodeAction.new(
253
253
  title: "Convert to keyword arguments",
254
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
255
- edit: multi_edit_workspace_edit([edit]),
254
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
255
+ edit: multi_edit_workspace_edit([edit])
256
256
  )
257
257
  end
258
258
 
@@ -261,16 +261,16 @@ module RubyLsp
261
261
  parts = []
262
262
 
263
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
264
+ parts << if required_names.include?(p.name)
265
+ "#{p.name}:"
266
+ else
267
+ p.location.slice.strip
268
+ end
269
269
  end
270
270
 
271
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 }
272
+ parts << params_node.rest.location.slice.strip if params_node.rest
273
+ params_node.keywords.each { |p| parts << p.location.slice.strip }
274
274
  parts << params_node.keyword_rest.location.slice.strip if params_node.keyword_rest
275
275
  parts << params_node.block.location.slice.strip if params_node.block
276
276
 
@@ -297,9 +297,9 @@ module RubyLsp
297
297
  insert_edit = Interface::TextEdit.new(
298
298
  range: Interface::Range.new(
299
299
  start: Interface::Position.new(line: insert_line, character: 0),
300
- end: Interface::Position.new(line: insert_line, character: 0),
300
+ end: Interface::Position.new(line: insert_line, character: 0)
301
301
  ),
302
- new_text: let_text,
302
+ new_text: let_text
303
303
  )
304
304
 
305
305
  # Delete the original assignment line inside the example.
@@ -307,8 +307,8 @@ module RubyLsp
307
307
 
308
308
  @response_builder << Interface::CodeAction.new(
309
309
  title: "Extract to let(:#{var_name})",
310
- kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
311
- edit: multi_edit_workspace_edit([insert_edit, delete_edit]),
310
+ kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
311
+ edit: multi_edit_workspace_edit([insert_edit, delete_edit])
312
312
  )
313
313
  end
314
314
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Simplifies redundant RuntimeError raises.
8
+ #
9
+ # Emitted actions
10
+ # ───────────────
11
+ # 1. Simplify raise
12
+ # raise RuntimeError, "msg" → raise "msg"
13
+ # fail RuntimeError, "msg" → fail "msg"
14
+ #
15
+ # RuntimeError is Ruby's default exception class; passing it explicitly
16
+ # is redundant. Only the two-argument form (class, message) is handled —
17
+ # `raise RuntimeError.new("msg")` is left alone because the intent may be
18
+ # to call a custom initializer.
19
+ class RaiseListener
20
+ include RubyLsp::Requests::Support::Common
21
+ include Support::NodeHelpers
22
+
23
+ def initialize(response_builder, node_context, dispatcher)
24
+ @response_builder = response_builder
25
+ @node_context = node_context
26
+
27
+ dispatcher.register(self, :on_call_node_enter)
28
+ end
29
+
30
+ def on_call_node_enter(node)
31
+ return unless node_covers_cursor?(node)
32
+ return unless raise_or_fail?(node)
33
+ return unless redundant_runtime_error?(node)
34
+
35
+ emit_simplify_raise(node)
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def raise_or_fail?(node)
43
+ %i[raise fail].include?(node.name)
44
+ end
45
+
46
+ # Returns true when the call is `raise RuntimeError, <message>` —
47
+ # exactly two arguments where the first is the constant RuntimeError.
48
+ def redundant_runtime_error?(node)
49
+ args = node.arguments&.arguments
50
+ return false unless args&.length == 2
51
+
52
+ first = args[0]
53
+ first.is_a?(Prism::ConstantReadNode) && first.name == :RuntimeError
54
+ end
55
+
56
+ def emit_simplify_raise(node)
57
+ keyword = node.name.to_s # "raise" or "fail"
58
+ msg_src = node.arguments.arguments[1].location.slice.strip
59
+ indent = indent_for(node)
60
+ new_text = "#{indent}#{keyword} #{msg_src}"
61
+
62
+ @response_builder << Interface::CodeAction.new(
63
+ title: "Simplify raise (remove redundant RuntimeError)",
64
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
65
+ edit: single_edit_workspace_edit(node, new_text)
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Offers "Wrap body in rescue" on any DefNode whose body does not already
8
+ # contain a rescue clause.
9
+ #
10
+ # Input (cursor anywhere inside the def):
11
+ # def call
12
+ # do_thing
13
+ # another_thing
14
+ # end
15
+ #
16
+ # Output:
17
+ # def call
18
+ # do_thing
19
+ # another_thing
20
+ # rescue StandardError => e
21
+ # raise
22
+ # end
23
+ #
24
+ # The generated rescue clause uses `raise` to re-raise by default so the
25
+ # developer can fill in the actual error handling without accidentally
26
+ # swallowing exceptions.
27
+ class RescueListener
28
+ include RubyLsp::Requests::Support::Common
29
+ include Support::NodeHelpers
30
+
31
+ def initialize(response_builder, node_context, dispatcher)
32
+ @response_builder = response_builder
33
+ @node_context = node_context
34
+
35
+ dispatcher.register(self, :on_def_node_enter)
36
+ end
37
+
38
+ def on_def_node_enter(node)
39
+ return unless node_covers_cursor?(node)
40
+ return unless node.body.is_a?(Prism::StatementsNode) # already has rescue if BeginNode
41
+ return if node.body.body.empty?
42
+
43
+ emit_wrap_rescue(node)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def emit_wrap_rescue(def_node)
51
+ indent = indent_for(def_node)
52
+ body_indent = "#{indent} "
53
+
54
+ # Preserve the existing body lines verbatim.
55
+ body_src = def_node.body.body
56
+ .map { |s| "#{body_indent}#{s.location.slice.strip}" }
57
+ .join("\n")
58
+
59
+ # Reconstruct the full def with a rescue clause appended before `end`.
60
+ def_header = build_def_header(def_node)
61
+ new_text = <<~RUBY.chomp
62
+ #{indent}#{def_header}
63
+ #{body_src}
64
+ #{body_indent}rescue StandardError => e
65
+ #{body_indent} raise
66
+ #{indent}end
67
+ RUBY
68
+
69
+ @response_builder << Interface::CodeAction.new(
70
+ title: "Wrap body in rescue",
71
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
72
+ edit: single_edit_workspace_edit(def_node, new_text)
73
+ )
74
+ end
75
+
76
+ # Reconstructs the `def name(params)` header line from the node's locations.
77
+ def build_def_header(node)
78
+ src = node.location.slice
79
+ # Take everything up to and including the closing paren (or method name
80
+ # when there are no params), stopping before the newline.
81
+ src.lines.first.rstrip
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Toggles between `let` and `let!` on RSpec lazy/eager memoization helpers.
8
+ #
9
+ # Emitted actions
10
+ # ───────────────
11
+ # 1. Convert let → let!
12
+ # let(:user) { User.new } → let!(:user) { User.new }
13
+ #
14
+ # 2. Convert let! → let
15
+ # let!(:user) { User.new } → let(:user) { User.new }
16
+ class RspecLetListener
17
+ include RubyLsp::Requests::Support::Common
18
+ include Support::NodeHelpers
19
+
20
+ def initialize(response_builder, node_context, dispatcher)
21
+ @response_builder = response_builder
22
+ @node_context = node_context
23
+
24
+ dispatcher.register(self, :on_call_node_enter)
25
+ end
26
+
27
+ def on_call_node_enter(node)
28
+ return unless node_covers_cursor?(node)
29
+ return unless let_call?(node)
30
+
31
+ if node.name == :let
32
+ emit_toggle(node, "let", "let!")
33
+ else
34
+ emit_toggle(node, "let!", "let")
35
+ end
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ private
41
+
42
+ def let_call?(node)
43
+ %i[let let!].include?(node.name) &&
44
+ node.block.is_a?(Prism::BlockNode) &&
45
+ node.arguments&.arguments&.first.is_a?(Prism::SymbolNode)
46
+ end
47
+
48
+ def emit_toggle(node, from_kw, to_kw)
49
+ # Replace only the method name portion, preserving arguments and block.
50
+ src = node.location.slice
51
+ new_text = "#{indent_for(node)}#{src.sub(/\A(\s*)#{Regexp.escape(from_kw)}/, "\\1#{to_kw}")}"
52
+
53
+ @response_builder << Interface::CodeAction.new(
54
+ title: "Convert #{from_kw} to #{to_kw}",
55
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
56
+ edit: single_edit_workspace_edit(node, new_text)
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Converts a bracket array of plain string literals into a %w[] word array,
8
+ # and vice-versa.
9
+ #
10
+ # Emitted actions
11
+ # ───────────────
12
+ # 1. Convert to string array (%w[])
13
+ # ["foo", "bar", "baz"] → %w[foo bar baz]
14
+ #
15
+ # 2. Convert to bracket array
16
+ # %w[foo bar baz] → ["foo", "bar", "baz"]
17
+ #
18
+ # Only plain string literals with no interpolation and no spaces in their
19
+ # content are eligible for compression into %w[].
20
+ class StringArrayListener
21
+ include RubyLsp::Requests::Support::Common
22
+ include Support::NodeHelpers
23
+
24
+ def initialize(response_builder, node_context, dispatcher)
25
+ @response_builder = response_builder
26
+ @node_context = node_context
27
+
28
+ dispatcher.register(self, :on_array_node_enter)
29
+ end
30
+
31
+ def on_array_node_enter(node)
32
+ return unless node_covers_cursor?(node)
33
+ return if node.elements.empty?
34
+
35
+ if bracket_string_array?(node)
36
+ emit_to_percent_w(node)
37
+ elsif percent_w_array?(node)
38
+ emit_to_bracket(node)
39
+ end
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ # Returns true when every element is a plain double-quoted StringNode
47
+ # with no interpolation and no spaces in its content.
48
+ def bracket_string_array?(node)
49
+ return false unless node.opening_loc&.slice == "["
50
+
51
+ node.elements.all? do |el|
52
+ el.is_a?(Prism::StringNode) &&
53
+ el.opening_loc&.slice == '"' &&
54
+ !el.unescaped.include?(" ") &&
55
+ !el.unescaped.include?("\t")
56
+ end
57
+ end
58
+
59
+ # Returns true when the array uses %w[] or %W[] syntax.
60
+ def percent_w_array?(node)
61
+ opening = node.opening_loc&.slice.to_s
62
+ opening.start_with?("%w", "%W")
63
+ end
64
+
65
+ def emit_to_percent_w(node)
66
+ words = node.elements.map(&:unescaped)
67
+ new_text = "%w[#{words.join(" ")}]"
68
+
69
+ @response_builder << Interface::CodeAction.new(
70
+ title: "Convert to string array (%w[])",
71
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
72
+ edit: single_edit_workspace_edit(node, new_text)
73
+ )
74
+ end
75
+
76
+ def emit_to_bracket(node)
77
+ words = node.elements.map { |el| "\"#{el.unescaped}\"" }
78
+ new_text = "[#{words.join(", ")}]"
79
+
80
+ @response_builder << Interface::CodeAction.new(
81
+ title: "Convert to bracket array",
82
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
83
+ edit: single_edit_workspace_edit(node, new_text)
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end