ruby-lsp-refactor 0.1.0 → 0.1.2

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +68 -0
  3. data/README.md +553 -115
  4. data/lib/ruby/lsp/refactor/version.rb +1 -1
  5. data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +54 -17
  6. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/accessor_listener.rb +156 -0
  7. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +2 -2
  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/early_return_listener.rb +105 -0
  11. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/enumerable_listener.rb +90 -0
  12. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/extract_include_file_listener.rb +172 -0
  13. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/extract_predicate_listener.rb +138 -0
  14. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +7 -7
  15. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/logical_operator_listener.rb +70 -0
  16. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +37 -109
  17. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/raise_listener.rb +70 -0
  18. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rescue_listener.rb +85 -0
  19. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rspec_let_listener.rb +61 -0
  20. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_array_listener.rb +88 -0
  21. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_freeze_listener.rb +86 -0
  22. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +2 -2
  23. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/super_listener.rb +95 -0
  24. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/tap_listener.rb +133 -0
  25. data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +7 -49
  26. data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +62 -12
  27. data/lib/ruby_lsp/test_helper.rb +5 -5
  28. data/test/ruby_lsp_refactor/accessor_listener_test.rb +91 -0
  29. data/test/ruby_lsp_refactor/constant_listener_test.rb +68 -0
  30. data/test/ruby_lsp_refactor/early_return_listener_test.rb +156 -0
  31. data/test/ruby_lsp_refactor/enumerable_listener_test.rb +80 -0
  32. data/test/ruby_lsp_refactor/extract_include_file_listener_test.rb +189 -0
  33. data/test/ruby_lsp_refactor/extract_predicate_listener_test.rb +113 -0
  34. data/test/ruby_lsp_refactor/logical_operator_listener_test.rb +66 -0
  35. data/test/ruby_lsp_refactor/method_listener_test.rb +3 -52
  36. data/test/ruby_lsp_refactor/raise_listener_test.rb +64 -0
  37. data/test/ruby_lsp_refactor/rescue_listener_test.rb +72 -0
  38. data/test/ruby_lsp_refactor/rspec_let_listener_test.rb +54 -0
  39. data/test/ruby_lsp_refactor/string_array_listener_test.rb +64 -0
  40. data/test/ruby_lsp_refactor/string_freeze_listener_test.rb +52 -0
  41. data/test/ruby_lsp_refactor/string_listener_test.rb +2 -2
  42. data/test/ruby_lsp_refactor/super_listener_test.rb +65 -0
  43. data/test/ruby_lsp_refactor/tap_listener_test.rb +144 -0
  44. data/test/ruby_lsp_refactor/variable_listener_test.rb +0 -23
  45. metadata +42 -13
@@ -3,7 +3,7 @@
3
3
  module Ruby
4
4
  module Lsp
5
5
  module Refactor
6
- VERSION = "0.1.0"
6
+ VERSION = "0.1.2"
7
7
  end
8
8
  end
9
9
  end
@@ -1,14 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ruby_lsp/addon"
4
- require "ruby_lsp/requests/code_actions"
5
- require "ruby_lsp/requests/code_action_resolve"
4
+
5
+ # Conditionals
6
6
  require_relative "listeners/conditional_listener"
7
- require_relative "listeners/variable_listener"
7
+ require_relative "listeners/early_return_listener"
8
+
9
+ # Strings
8
10
  require_relative "listeners/string_listener"
9
- require_relative "listeners/hash_listener"
11
+ require_relative "listeners/string_array_listener"
12
+ require_relative "listeners/string_freeze_listener"
13
+
14
+ # Collections
10
15
  require_relative "listeners/array_listener"
16
+ require_relative "listeners/hash_listener"
17
+ require_relative "listeners/enumerable_listener"
18
+
19
+ # Variables & constants
20
+ require_relative "listeners/variable_listener"
21
+ require_relative "listeners/constant_listener"
22
+
23
+ # Methods & classes
11
24
  require_relative "listeners/method_listener"
25
+ require_relative "listeners/extract_predicate_listener"
26
+ require_relative "listeners/accessor_listener"
27
+ require_relative "listeners/rescue_listener"
28
+ require_relative "listeners/super_listener"
29
+
30
+ # Multi-file
31
+ require_relative "listeners/extract_include_file_listener"
32
+
33
+ # Operators & blocks
34
+ require_relative "listeners/tap_listener"
35
+ require_relative "listeners/logical_operator_listener"
36
+ require_relative "listeners/raise_listener"
37
+
38
+ # RSpec
39
+ require_relative "listeners/rspec_let_listener"
12
40
 
13
41
  module RubyLsp
14
42
  module Refactor
@@ -31,7 +59,7 @@ module RubyLsp
31
59
 
32
60
  class Addon < ::RubyLsp::Addon
33
61
  # Called once when the language server activates this add-on.
34
- def activate(global_state, message_queue)
62
+ def activate(global_state, _message_queue)
35
63
  @global_state = global_state
36
64
 
37
65
  # Inject our actions into the standard code-actions response.
@@ -62,30 +90,39 @@ module RubyLsp
62
90
 
63
91
  cursor_range = Interface::Range.new(
64
92
  start: Interface::Position.new(
65
- line: range.dig(:start, :line),
66
- character: range.dig(:start, :character),
93
+ line: range.dig(:start, :line),
94
+ character: range.dig(:start, :character)
67
95
  ),
68
96
  end: Interface::Position.new(
69
- line: range.dig(:end, :line),
70
- character: range.dig(:end, :character),
71
- ),
97
+ line: range.dig(:end, :line),
98
+ character: range.dig(:end, :character)
99
+ )
72
100
  )
73
101
 
74
102
  node_context = NodeContext.new(document.uri.to_s, cursor_range)
75
103
  response_builder = RubyLsp::ResponseBuilders::CollectionResponseBuilder.new
76
104
  dispatcher = Prism::Dispatcher.new
77
105
 
78
- # Phase 1 – Local rewrites
79
106
  ConditionalListener.new(response_builder, node_context, dispatcher)
107
+ EarlyReturnListener.new(response_builder, node_context, dispatcher)
80
108
  StringListener.new(response_builder, node_context, dispatcher)
81
-
82
- # Phase 2 – Variable & literal optimisation
83
- VariableListener.new(response_builder, node_context, dispatcher)
84
- HashListener.new(response_builder, node_context, dispatcher)
109
+ StringArrayListener.new(response_builder, node_context, dispatcher)
110
+ StringFreezeListener.new(response_builder, node_context, dispatcher)
85
111
  ArrayListener.new(response_builder, node_context, dispatcher)
86
-
87
- # Phase 3 – Advanced structure
112
+ HashListener.new(response_builder, node_context, dispatcher)
113
+ EnumerableListener.new(response_builder, node_context, dispatcher)
114
+ VariableListener.new(response_builder, node_context, dispatcher)
115
+ ConstantListener.new(response_builder, node_context, dispatcher)
88
116
  MethodListener.new(response_builder, node_context, dispatcher)
117
+ ExtractPredicateListener.new(response_builder, node_context, dispatcher)
118
+ AccessorListener.new(response_builder, node_context, dispatcher)
119
+ RescueListener.new(response_builder, node_context, dispatcher)
120
+ SuperListener.new(response_builder, node_context, dispatcher)
121
+ ExtractIncludeFileListener.new(response_builder, node_context, dispatcher)
122
+ TapListener.new(response_builder, node_context, dispatcher)
123
+ LogicalOperatorListener.new(response_builder, node_context, dispatcher)
124
+ RaiseListener.new(response_builder, node_context, dispatcher)
125
+ RspecLetListener.new(response_builder, node_context, dispatcher)
89
126
 
90
127
  dispatcher.dispatch(document.ast)
91
128
  response_builder.response
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Detects an `attr_reader :name` paired with a canonical manual writer
8
+ # `def name=(val); @name = val; end` in the same class body and offers
9
+ # to collapse them into a single `attr_accessor :name`.
10
+ #
11
+ # Input (cursor on either the attr_reader or the writer def):
12
+ # attr_reader :name
13
+ # def name=(val)
14
+ # @name = val
15
+ # end
16
+ #
17
+ # Output:
18
+ # attr_accessor :name
19
+ #
20
+ # The manual writer is considered canonical when its body contains exactly
21
+ # one statement of the form `@name = val` where `val` is the sole parameter.
22
+ class AccessorListener
23
+ include RubyLsp::Requests::Support::Common
24
+ include Support::NodeHelpers
25
+
26
+ def initialize(response_builder, node_context, dispatcher)
27
+ @response_builder = response_builder
28
+ @node_context = node_context
29
+
30
+ # Collect attr_reader calls and writer defs within the same class body.
31
+ @attr_readers = [] # [{ name:, node: }]
32
+ @writer_defs = [] # [{ name:, node: }]
33
+ @class_depth = 0
34
+
35
+ dispatcher.register(
36
+ self,
37
+ :on_class_node_enter,
38
+ :on_class_node_leave,
39
+ :on_call_node_enter,
40
+ :on_def_node_enter
41
+ )
42
+ end
43
+
44
+ def on_class_node_enter(_node)
45
+ @class_depth += 1
46
+ end
47
+
48
+ def on_class_node_leave(_node)
49
+ @class_depth -= 1
50
+ # Emit any matched pairs before clearing — on_program_node_leave fires
51
+ # after on_class_node_leave, so we must act here while data is present.
52
+ emit_matching_pairs
53
+ @attr_readers.clear
54
+ @writer_defs.clear
55
+ end
56
+
57
+ def on_call_node_enter(node)
58
+ return unless @class_depth.positive?
59
+ return unless node.name == :attr_reader
60
+ return unless node.arguments
61
+
62
+ node.arguments.arguments.each do |arg|
63
+ next unless arg.is_a?(Prism::SymbolNode)
64
+
65
+ @attr_readers << { name: arg.unescaped.to_sym, node: node, sym_node: arg }
66
+ end
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ def on_def_node_enter(node)
72
+ return unless @class_depth.positive?
73
+ return unless node.name.to_s.end_with?("=")
74
+
75
+ attr_name = node.name.to_s.delete_suffix("=").to_sym
76
+ return unless canonical_writer?(node, attr_name)
77
+
78
+ @writer_defs << { name: attr_name, node: node }
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ private
84
+
85
+ # Match readers with writers and emit actions for any pair where the
86
+ # cursor falls on either node.
87
+ def emit_matching_pairs
88
+ @attr_readers.each do |reader|
89
+ writer = @writer_defs.find { |w| w[:name] == reader[:name] }
90
+ next unless writer
91
+
92
+ next unless node_covers_cursor?(reader[:node]) ||
93
+ node_covers_cursor?(writer[:node])
94
+
95
+ emit_collapse(reader, writer)
96
+ end
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ # A writer def is canonical when:
102
+ # 1. It has exactly one required parameter.
103
+ # 2. Its body is exactly one statement: `@name = <param>`.
104
+ def canonical_writer?(def_node, attr_name)
105
+ params = def_node.parameters&.requireds
106
+ return false unless params&.length == 1
107
+
108
+ param_name = params.first.name
109
+ body_stmts = def_node.body&.body
110
+ return false unless body_stmts&.length == 1
111
+
112
+ stmt = body_stmts.first
113
+ return false unless stmt.is_a?(Prism::InstanceVariableWriteNode)
114
+ return false unless stmt.name.to_s == "@#{attr_name}"
115
+ return false unless stmt.value.is_a?(Prism::LocalVariableReadNode)
116
+ return false unless stmt.value.name == param_name
117
+
118
+ true
119
+ end
120
+
121
+ def emit_collapse(reader, writer)
122
+ reader_node = reader[:node]
123
+ writer_node = writer[:node]
124
+ attr_name = reader[:name]
125
+
126
+ # Replace the attr_reader line with attr_accessor.
127
+ # The sym_node is the :name argument; we keep the same symbol.
128
+ reader_src = reader_node.location.slice
129
+ new_reader = reader_src.sub("attr_reader", "attr_accessor")
130
+
131
+ replace_reader = Interface::TextEdit.new(
132
+ range: node_to_lsp_range(reader_node),
133
+ new_text: new_reader
134
+ )
135
+
136
+ # Delete the entire writer def (all its lines).
137
+ writer_start_line = writer_node.location.start_line - 1
138
+ writer_end_line = writer_node.location.end_line
139
+
140
+ delete_writer = Interface::TextEdit.new(
141
+ range: Interface::Range.new(
142
+ start: Interface::Position.new(line: writer_start_line, character: 0),
143
+ end: Interface::Position.new(line: writer_end_line, character: 0)
144
+ ),
145
+ new_text: ""
146
+ )
147
+
148
+ @response_builder << Interface::CodeAction.new(
149
+ title: "Convert to attr_accessor :#{attr_name}",
150
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
151
+ edit: multi_edit_workspace_edit([replace_reader, delete_writer])
152
+ )
153
+ end
154
+ end
155
+ end
156
+ end
@@ -56,8 +56,8 @@ module RubyLsp
56
56
 
57
57
  @response_builder << Interface::CodeAction.new(
58
58
  title: "Convert to symbol array",
59
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
60
- edit: single_edit_workspace_edit(node, new_text),
59
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
60
+ edit: single_edit_workspace_edit(node, new_text)
61
61
  )
62
62
  end
63
63
  end
@@ -127,8 +127,8 @@ module RubyLsp
127
127
 
128
128
  @response_builder << Interface::CodeAction.new(
129
129
  title: "Convert to post-conditional",
130
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
131
- edit: single_edit_workspace_edit(node, new_text),
130
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
131
+ edit: single_edit_workspace_edit(node, new_text)
132
132
  )
133
133
  end
134
134
 
@@ -141,8 +141,8 @@ module RubyLsp
141
141
 
142
142
  @response_builder << Interface::CodeAction.new(
143
143
  title: "Convert to post-conditional",
144
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
145
- edit: single_edit_workspace_edit(node, new_text),
144
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
145
+ edit: single_edit_workspace_edit(node, new_text)
146
146
  )
147
147
  end
148
148
 
@@ -155,8 +155,8 @@ module RubyLsp
155
155
 
156
156
  @response_builder << Interface::CodeAction.new(
157
157
  title: "Convert to block if",
158
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
159
- edit: single_edit_workspace_edit(node, new_text),
158
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
159
+ edit: single_edit_workspace_edit(node, new_text)
160
160
  )
161
161
  end
162
162
 
@@ -169,8 +169,8 @@ module RubyLsp
169
169
 
170
170
  @response_builder << Interface::CodeAction.new(
171
171
  title: "Convert to block unless",
172
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
173
- edit: single_edit_workspace_edit(node, new_text),
172
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
173
+ edit: single_edit_workspace_edit(node, new_text)
174
174
  )
175
175
  end
176
176
 
@@ -183,8 +183,8 @@ module RubyLsp
183
183
 
184
184
  @response_builder << Interface::CodeAction.new(
185
185
  title: "Convert to unless",
186
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
187
- edit: single_edit_workspace_edit(node, new_text),
186
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
187
+ edit: single_edit_workspace_edit(node, new_text)
188
188
  )
189
189
  end
190
190
 
@@ -197,8 +197,8 @@ module RubyLsp
197
197
 
198
198
  @response_builder << Interface::CodeAction.new(
199
199
  title: "Convert to if",
200
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
201
- edit: single_edit_workspace_edit(node, new_text),
200
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
201
+ edit: single_edit_workspace_edit(node, new_text)
202
202
  )
203
203
  end
204
204
 
@@ -212,8 +212,8 @@ module RubyLsp
212
212
 
213
213
  @response_builder << Interface::CodeAction.new(
214
214
  title: "Invert if/else",
215
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
216
- edit: single_edit_workspace_edit(node, new_text),
215
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
216
+ edit: single_edit_workspace_edit(node, new_text)
217
217
  )
218
218
  end
219
219
 
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Offers "Extract constant" when the cursor is on a literal value
8
+ # (Integer, Float, String, Symbol) inside a class or module body.
9
+ #
10
+ # The new constant is inserted at the top of the enclosing class/module
11
+ # body, and the literal is replaced with the constant name.
12
+ #
13
+ # Input (cursor on `100`):
14
+ # class Processor
15
+ # def run
16
+ # items.first(100)
17
+ # end
18
+ # end
19
+ #
20
+ # Output:
21
+ # class Processor
22
+ # EXTRACTED_CONSTANT = 100
23
+ #
24
+ # def run
25
+ # items.first(EXTRACTED_CONSTANT)
26
+ # end
27
+ # end
28
+ class ConstantListener
29
+ include RubyLsp::Requests::Support::Common
30
+ include Support::NodeHelpers
31
+
32
+ LITERAL_NODE_TYPES = [
33
+ Prism::IntegerNode,
34
+ Prism::FloatNode,
35
+ Prism::StringNode,
36
+ Prism::SymbolNode
37
+ ].freeze
38
+
39
+ def initialize(response_builder, node_context, dispatcher)
40
+ @response_builder = response_builder
41
+ @node_context = node_context
42
+
43
+ # Track the nearest enclosing class/module so we know where to insert.
44
+ @class_stack = []
45
+
46
+ dispatcher.register(
47
+ self,
48
+ :on_class_node_enter,
49
+ :on_class_node_leave,
50
+ :on_module_node_enter,
51
+ :on_module_node_leave,
52
+ :on_integer_node_enter,
53
+ :on_float_node_enter,
54
+ :on_string_node_enter,
55
+ :on_symbol_node_enter
56
+ )
57
+ end
58
+
59
+ def on_class_node_enter(node) = @class_stack.push(node)
60
+ def on_class_node_leave(_node) = @class_stack.pop
61
+ def on_module_node_enter(node) = @class_stack.push(node)
62
+ def on_module_node_leave(_node) = @class_stack.pop
63
+
64
+ def on_integer_node_enter(node) = maybe_emit(node)
65
+ def on_float_node_enter(node) = maybe_emit(node)
66
+ def on_string_node_enter(node) = maybe_emit(node)
67
+ def on_symbol_node_enter(node) = maybe_emit(node)
68
+
69
+ private
70
+
71
+ def maybe_emit(node)
72
+ return unless node_covers_cursor?(node)
73
+ return if @class_stack.empty?
74
+
75
+ # Don't offer on constant assignments themselves.
76
+ enclosing = @class_stack.last
77
+ return unless enclosing
78
+
79
+ emit_extract_constant(node, enclosing)
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def emit_extract_constant(literal_node, class_node)
85
+ literal_src = literal_node.location.slice.strip
86
+ indent = indent_for(class_node)
87
+ body_indent = "#{indent} "
88
+
89
+ # Insert the constant declaration at the top of the class body.
90
+ # The class body starts on the line after the class declaration.
91
+ body_start_line = class_node.body&.location&.start_line
92
+ return unless body_start_line
93
+
94
+ insert_line = body_start_line - 1
95
+ const_decl = "#{body_indent}EXTRACTED_CONSTANT = #{literal_src}\n\n"
96
+
97
+ insert_edit = Interface::TextEdit.new(
98
+ range: Interface::Range.new(
99
+ start: Interface::Position.new(line: insert_line, character: 0),
100
+ end: Interface::Position.new(line: insert_line, character: 0)
101
+ ),
102
+ new_text: const_decl
103
+ )
104
+
105
+ replace_edit = Interface::TextEdit.new(
106
+ range: node_to_lsp_range(literal_node),
107
+ new_text: "EXTRACTED_CONSTANT"
108
+ )
109
+
110
+ @response_builder << Interface::CodeAction.new(
111
+ title: "Extract constant",
112
+ kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
113
+ edit: multi_edit_workspace_edit([insert_edit, replace_edit])
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Offers "Convert to early return" when the cursor is on a guard `if` block
8
+ # that is the first statement in a method body and has no `else` branch.
9
+ #
10
+ # From "Prefer early returns" in Refactoring Rails: replace a top-level
11
+ # guard `if` with `return unless` so the happy path is not nested.
12
+ #
13
+ # Input (cursor on the `if` line):
14
+ # def charge_purchase(order)
15
+ # if order.fulfilled?
16
+ # OrderChargeConfirmation.new(order).create!
17
+ # end
18
+ # end
19
+ #
20
+ # Output:
21
+ # def charge_purchase(order)
22
+ # return unless order.fulfilled?
23
+ # OrderChargeConfirmation.new(order).create!
24
+ # end
25
+ #
26
+ # Eligibility:
27
+ # - Block-form `if` (has end_keyword_loc) with no else/elsif.
28
+ # - Must be the first statement in the enclosing method body.
29
+ # - Body may contain one or more statements.
30
+ class EarlyReturnListener
31
+ include RubyLsp::Requests::Support::Common
32
+ include Support::NodeHelpers
33
+
34
+ def initialize(response_builder, node_context, dispatcher)
35
+ @response_builder = response_builder
36
+ @node_context = node_context
37
+ @current_def = nil
38
+
39
+ dispatcher.register(
40
+ self,
41
+ :on_def_node_enter,
42
+ :on_def_node_leave,
43
+ :on_if_node_enter
44
+ )
45
+ end
46
+
47
+ def on_def_node_enter(node)
48
+ @current_def = node
49
+ rescue StandardError
50
+ nil
51
+ end
52
+
53
+ def on_def_node_leave(_node)
54
+ @current_def = nil
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def on_if_node_enter(node)
60
+ return unless node_covers_cursor?(node)
61
+ return unless eligible?(node)
62
+
63
+ emit_early_return(node)
64
+ rescue StandardError
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ def eligible?(node)
71
+ return false unless node.end_keyword_loc # block-form only
72
+ return false if node.subsequent # no else/elsif
73
+ return false unless node.statements&.body&.any?
74
+ return false unless first_statement_in_def?(node)
75
+
76
+ true
77
+ end
78
+
79
+ def first_statement_in_def?(if_node)
80
+ return false unless @current_def
81
+
82
+ body = @current_def.body
83
+ return false unless body.is_a?(Prism::StatementsNode)
84
+
85
+ body.body.first.equal?(if_node)
86
+ end
87
+
88
+ def emit_early_return(node)
89
+ indent = indent_for(node)
90
+ cond_src = node.predicate.location.slice.strip
91
+ body_lines = node.statements.body
92
+ .map { |s| "#{indent}#{s.location.slice.strip}" }
93
+ .join("\n")
94
+
95
+ new_text = "#{indent}return unless #{cond_src}\n#{body_lines}"
96
+
97
+ @response_builder << Interface::CodeAction.new(
98
+ title: "Convert to early return",
99
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
100
+ edit: single_edit_workspace_edit(node, new_text)
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
@@ -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