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
@@ -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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Offers "Wrap in freeze" on any unfrozen string literal, and
8
+ # "Remove freeze" on a string that already calls .freeze.
9
+ #
10
+ # Emitted actions
11
+ # ───────────────
12
+ # 1. Wrap in freeze
13
+ # "hello" → "hello".freeze
14
+ #
15
+ # 2. Remove freeze
16
+ # "hello".freeze → "hello"
17
+ class StringFreezeListener
18
+ include RubyLsp::Requests::Support::Common
19
+ include Support::NodeHelpers
20
+
21
+ def initialize(response_builder, node_context, dispatcher)
22
+ @response_builder = response_builder
23
+ @node_context = node_context
24
+
25
+ # Track start offsets of string nodes that are already receivers of
26
+ # .freeze so on_string_node_enter can skip them. Populated during
27
+ # on_call_node_enter which fires for the outer CallNode first.
28
+ @frozen_string_offsets = {}
29
+
30
+ dispatcher.register(self, :on_call_node_enter, :on_string_node_enter)
31
+ end
32
+
33
+ # Fires for every CallNode — including `.freeze` calls.
34
+ # We register this before on_string_node_enter so the offset set is
35
+ # populated before the inner StringNode callback fires.
36
+ def on_call_node_enter(node)
37
+ return unless node.name == :freeze
38
+ return unless node.receiver.is_a?(Prism::StringNode)
39
+ return unless node.arguments.nil?
40
+
41
+ # Mark the receiver string so on_string_node_enter skips it.
42
+ @frozen_string_offsets[node.receiver.location.start_offset] = true
43
+
44
+ return unless node_covers_cursor?(node)
45
+
46
+ emit_remove_freeze(node)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ # Offer "Wrap in freeze" for plain string literals that are not already
52
+ # the receiver of a .freeze call.
53
+ def on_string_node_enter(node)
54
+ return unless node_covers_cursor?(node)
55
+ return if @frozen_string_offsets.key?(node.location.start_offset)
56
+
57
+ emit_wrap_freeze(node)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ private
63
+
64
+ def emit_wrap_freeze(node)
65
+ str_src = node.location.slice
66
+ new_text = "#{str_src}.freeze"
67
+
68
+ @response_builder << Interface::CodeAction.new(
69
+ title: "Wrap in freeze",
70
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
71
+ edit: single_edit_workspace_edit(node, new_text)
72
+ )
73
+ end
74
+
75
+ def emit_remove_freeze(node)
76
+ str_src = node.receiver.location.slice
77
+
78
+ @response_builder << Interface::CodeAction.new(
79
+ title: "Remove freeze",
80
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
81
+ edit: single_edit_workspace_edit(node, str_src)
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -52,8 +52,8 @@ module RubyLsp
52
52
 
53
53
  @response_builder << Interface::CodeAction.new(
54
54
  title: "Convert to interpolated string",
55
- kind: Constant::CodeActionKind::REFACTOR_REWRITE,
56
- edit: single_edit_workspace_edit(node, new_text),
55
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
56
+ edit: single_edit_workspace_edit(node, new_text)
57
57
  )
58
58
  end
59
59
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Converts a bare `super` (ForwardingSuperNode) inside a def that has
8
+ # parameters into an explicit `super(param1, param2, ...)`.
9
+ #
10
+ # Input (cursor on `super`):
11
+ # def initialize(name, age)
12
+ # super
13
+ # end
14
+ #
15
+ # Output:
16
+ # def initialize(name, age)
17
+ # super(name, age)
18
+ # end
19
+ #
20
+ # Bare `super` forwards all arguments implicitly; making them explicit
21
+ # is safer when the method signature changes over time.
22
+ class SuperListener
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
+ @current_def = nil
31
+
32
+ dispatcher.register(
33
+ self,
34
+ :on_def_node_enter,
35
+ :on_def_node_leave,
36
+ :on_forwarding_super_node_enter
37
+ )
38
+ end
39
+
40
+ def on_def_node_enter(node)
41
+ @current_def = node
42
+ end
43
+
44
+ def on_def_node_leave(_node)
45
+ @current_def = nil
46
+ end
47
+
48
+ def on_forwarding_super_node_enter(node)
49
+ return unless node_covers_cursor?(node)
50
+ return unless @current_def
51
+ return unless has_params?(@current_def)
52
+
53
+ emit_explicit_super(node, @current_def)
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def has_params?(def_node)
61
+ params = def_node.parameters
62
+ return false unless params
63
+
64
+ params.requireds.any? || params.optionals.any? || params.keywords.any?
65
+ end
66
+
67
+ def emit_explicit_super(super_node, def_node)
68
+ param_names = collect_param_names(def_node.parameters)
69
+ new_text = "#{indent_for(super_node)}super(#{param_names.join(", ")})"
70
+
71
+ @response_builder << Interface::CodeAction.new(
72
+ title: "Convert to explicit super(#{param_names.join(", ")})",
73
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
74
+ edit: single_edit_workspace_edit(super_node, new_text)
75
+ )
76
+ end
77
+
78
+ def collect_param_names(params_node)
79
+ names = []
80
+ params_node.requireds.each do |p|
81
+ names << p.name.to_s if p.respond_to?(:name)
82
+ end
83
+ params_node.optionals.each do |p|
84
+ names << p.name.to_s if p.respond_to?(:name)
85
+ end
86
+ params_node.keywords.each do |p|
87
+ # keyword params: `name:` — pass as `name: name`
88
+ kw_name = p.name.to_s.delete_suffix(":")
89
+ names << "#{kw_name}: #{kw_name}"
90
+ end
91
+ names
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../support/node_helpers"
4
+
5
+ module RubyLsp
6
+ module Refactor
7
+ # Offers "Convert to tap" when the cursor is inside a method whose body
8
+ # is a sequence of calls on the same receiver followed by a bare return
9
+ # of that receiver.
10
+ #
11
+ # From "Encourage use of Object#tap" in Refactoring Rails: groups
12
+ # operations on the same object into a tap block and removes the explicit
13
+ # return at the end.
14
+ #
15
+ # Input (cursor anywhere inside the method):
16
+ # def do_something
17
+ # obj.do_first_thing
18
+ # obj.do_second_thing
19
+ # obj.do_third_thing
20
+ # obj
21
+ # end
22
+ #
23
+ # Output:
24
+ # def do_something
25
+ # obj.tap do |o|
26
+ # o.do_first_thing
27
+ # o.do_second_thing
28
+ # o.do_third_thing
29
+ # end
30
+ # end
31
+ #
32
+ # Eligibility:
33
+ # - Method body has at least two statements.
34
+ # - All statements except the last are CallNodes whose receiver is a
35
+ # variable_call (bare local variable or method with no receiver).
36
+ # - All those receivers share the same name.
37
+ # - The last statement is a bare variable_call with the same name.
38
+ class TapListener
39
+ include RubyLsp::Requests::Support::Common
40
+ include Support::NodeHelpers
41
+
42
+ def initialize(response_builder, node_context, dispatcher)
43
+ @response_builder = response_builder
44
+ @node_context = node_context
45
+
46
+ dispatcher.register(self, :on_def_node_enter)
47
+ end
48
+
49
+ def on_def_node_enter(node)
50
+ return unless node_covers_cursor?(node)
51
+ return unless (receiver_name = tap_eligible?(node))
52
+
53
+ emit_convert_to_tap(node, receiver_name)
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ # Returns the shared receiver name if the method body is tap-eligible,
61
+ # or nil otherwise.
62
+ def tap_eligible?(def_node)
63
+ body = def_node.body
64
+ return unless body.is_a?(Prism::StatementsNode)
65
+
66
+ stmts = body.body
67
+ return unless stmts.length >= 2
68
+
69
+ last = stmts.last
70
+ calls = stmts[0..-2]
71
+
72
+ # Last statement must be a bare variable_call.
73
+ return unless last.is_a?(Prism::CallNode) && last.variable_call?
74
+
75
+ receiver_name = last.name
76
+
77
+ # Every preceding statement must be a non-variable call whose receiver
78
+ # is a variable_call with the same name.
79
+ all_match = calls.all? do |c|
80
+ c.is_a?(Prism::CallNode) &&
81
+ !c.variable_call? &&
82
+ c.receiver.is_a?(Prism::CallNode) &&
83
+ c.receiver.variable_call? &&
84
+ c.receiver.name == receiver_name
85
+ end
86
+
87
+ receiver_name if all_match
88
+ end
89
+
90
+ def emit_convert_to_tap(def_node, receiver_name)
91
+ body_indent = "#{indent_for(def_node)} "
92
+ tap_indent = "#{body_indent} "
93
+
94
+ stmts = def_node.body.body
95
+ calls = stmts[0..-2]
96
+
97
+ # Rebuild each call as `o.method(args)` using the slice after the receiver.
98
+ tap_lines = calls.map do |c|
99
+ full = c.location.slice.strip
100
+ recv_src = c.receiver.location.slice.strip
101
+ # Everything after "recv." is the method call part.
102
+ method_part = full[(recv_src.length + 1)..]
103
+ "#{tap_indent}o.#{method_part}"
104
+ end.join("\n")
105
+
106
+ new_body = "#{body_indent}#{receiver_name}.tap do |o|\n" \
107
+ "#{tap_lines}\n" \
108
+ "#{body_indent}end"
109
+
110
+ # Replace the entire method body (all statements) with the tap block.
111
+ body_node = def_node.body
112
+ body_range = Interface::Range.new(
113
+ start: Interface::Position.new(
114
+ line: body_node.location.start_line - 1,
115
+ character: body_node.location.start_column
116
+ ),
117
+ end: Interface::Position.new(
118
+ line: body_node.location.end_line - 1,
119
+ character: body_node.location.end_column
120
+ )
121
+ )
122
+
123
+ edit = Interface::TextEdit.new(range: body_range, new_text: new_body)
124
+
125
+ @response_builder << Interface::CodeAction.new(
126
+ title: "Convert to tap",
127
+ kind: Constant::CodeActionKind::REFACTOR_REWRITE,
128
+ edit: multi_edit_workspace_edit([edit])
129
+ )
130
+ end
131
+ end
132
+ end
133
+ end