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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +68 -0
- data/README.md +553 -115
- data/lib/ruby/lsp/refactor/version.rb +1 -1
- data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +54 -17
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/accessor_listener.rb +156 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +2 -2
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/conditional_listener.rb +14 -14
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/constant_listener.rb +118 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/early_return_listener.rb +105 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/enumerable_listener.rb +90 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/extract_include_file_listener.rb +172 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/extract_predicate_listener.rb +138 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +7 -7
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/logical_operator_listener.rb +70 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +37 -109
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/raise_listener.rb +70 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rescue_listener.rb +85 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/rspec_let_listener.rb +61 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_array_listener.rb +88 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_freeze_listener.rb +86 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +2 -2
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/super_listener.rb +95 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/tap_listener.rb +133 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +7 -49
- data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +62 -12
- data/lib/ruby_lsp/test_helper.rb +5 -5
- data/test/ruby_lsp_refactor/accessor_listener_test.rb +91 -0
- data/test/ruby_lsp_refactor/constant_listener_test.rb +68 -0
- data/test/ruby_lsp_refactor/early_return_listener_test.rb +156 -0
- data/test/ruby_lsp_refactor/enumerable_listener_test.rb +80 -0
- data/test/ruby_lsp_refactor/extract_include_file_listener_test.rb +189 -0
- data/test/ruby_lsp_refactor/extract_predicate_listener_test.rb +113 -0
- data/test/ruby_lsp_refactor/logical_operator_listener_test.rb +66 -0
- data/test/ruby_lsp_refactor/method_listener_test.rb +3 -52
- data/test/ruby_lsp_refactor/raise_listener_test.rb +64 -0
- data/test/ruby_lsp_refactor/rescue_listener_test.rb +72 -0
- data/test/ruby_lsp_refactor/rspec_let_listener_test.rb +54 -0
- data/test/ruby_lsp_refactor/string_array_listener_test.rb +64 -0
- data/test/ruby_lsp_refactor/string_freeze_listener_test.rb +52 -0
- data/test/ruby_lsp_refactor/string_listener_test.rb +2 -2
- data/test/ruby_lsp_refactor/super_listener_test.rb +65 -0
- data/test/ruby_lsp_refactor/tap_listener_test.rb +144 -0
- data/test/ruby_lsp_refactor/variable_listener_test.rb +0 -23
- 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:
|
|
56
|
-
edit:
|
|
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
|