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
|
@@ -12,11 +12,8 @@ module RubyLsp
|
|
|
12
12
|
# result = user.calculate → (line deleted)
|
|
13
13
|
# puts result → puts user.calculate
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# inserted on the line above.
|
|
18
|
-
# user.full_name.upcase → name = user.full_name.upcase
|
|
19
|
-
# name
|
|
15
|
+
# Note: "Extract local variable" is provided by ruby-lsp upstream as
|
|
16
|
+
# "Refactor: Extract Variable" and is intentionally not duplicated here.
|
|
20
17
|
class VariableListener
|
|
21
18
|
include RubyLsp::Requests::Support::Common
|
|
22
19
|
include Support::NodeHelpers
|
|
@@ -40,8 +37,7 @@ module RubyLsp
|
|
|
40
37
|
self,
|
|
41
38
|
:on_local_variable_write_node_enter,
|
|
42
39
|
:on_local_variable_read_node_enter,
|
|
43
|
-
:
|
|
44
|
-
:on_program_node_leave,
|
|
40
|
+
:on_program_node_leave
|
|
45
41
|
)
|
|
46
42
|
end
|
|
47
43
|
|
|
@@ -63,15 +59,6 @@ module RubyLsp
|
|
|
63
59
|
nil
|
|
64
60
|
end
|
|
65
61
|
|
|
66
|
-
# Offer "Extract local variable" for any call expression under the cursor.
|
|
67
|
-
def on_call_node_enter(node)
|
|
68
|
-
return unless node_covers_cursor?(node)
|
|
69
|
-
|
|
70
|
-
emit_extract_local_variable(node)
|
|
71
|
-
rescue StandardError
|
|
72
|
-
nil
|
|
73
|
-
end
|
|
74
|
-
|
|
75
62
|
# After the full AST walk, all read nodes are known — emit inline actions.
|
|
76
63
|
def on_program_node_leave(_node)
|
|
77
64
|
@pending_write_nodes.each { |w| emit_inline_variable(w) }
|
|
@@ -89,44 +76,15 @@ module RubyLsp
|
|
|
89
76
|
|
|
90
77
|
@read_nodes[write_node.name].each do |read_node|
|
|
91
78
|
edits << Interface::TextEdit.new(
|
|
92
|
-
range:
|
|
93
|
-
new_text: rhs_text
|
|
79
|
+
range: node_to_lsp_range(read_node),
|
|
80
|
+
new_text: rhs_text
|
|
94
81
|
)
|
|
95
82
|
end
|
|
96
83
|
|
|
97
84
|
@response_builder << Interface::CodeAction.new(
|
|
98
85
|
title: "Inline variable '#{write_node.name}'",
|
|
99
|
-
kind:
|
|
100
|
-
edit:
|
|
101
|
-
)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# ── extract local variable ───────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
def emit_extract_local_variable(node)
|
|
107
|
-
expr_src = node.location.slice.strip
|
|
108
|
-
indent = " " * node.location.start_column
|
|
109
|
-
insert_line = node.location.start_line - 1
|
|
110
|
-
|
|
111
|
-
# Insert `variable = <expr>` on the line above, then replace the
|
|
112
|
-
# expression in-place with the variable name.
|
|
113
|
-
insert_edit = Interface::TextEdit.new(
|
|
114
|
-
range: Interface::Range.new(
|
|
115
|
-
start: Interface::Position.new(line: insert_line, character: 0),
|
|
116
|
-
end: Interface::Position.new(line: insert_line, character: 0),
|
|
117
|
-
),
|
|
118
|
-
new_text: "#{indent}variable = #{expr_src}\n",
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
replace_edit = Interface::TextEdit.new(
|
|
122
|
-
range: node_to_lsp_range(node),
|
|
123
|
-
new_text: "variable",
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
@response_builder << Interface::CodeAction.new(
|
|
127
|
-
title: "Extract local variable",
|
|
128
|
-
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
129
|
-
edit: multi_edit_workspace_edit([insert_edit, replace_edit]),
|
|
86
|
+
kind: Constant::CodeActionKind::REFACTOR_INLINE,
|
|
87
|
+
edit: multi_edit_workspace_edit(edits)
|
|
130
88
|
)
|
|
131
89
|
end
|
|
132
90
|
end
|
|
@@ -18,13 +18,13 @@ module RubyLsp
|
|
|
18
18
|
loc = node.location
|
|
19
19
|
Interface::Range.new(
|
|
20
20
|
start: Interface::Position.new(
|
|
21
|
-
line:
|
|
22
|
-
character: loc.start_column
|
|
21
|
+
line: loc.start_line - 1,
|
|
22
|
+
character: loc.start_column
|
|
23
23
|
),
|
|
24
24
|
end: Interface::Position.new(
|
|
25
|
-
line:
|
|
26
|
-
character: loc.end_column
|
|
27
|
-
)
|
|
25
|
+
line: loc.end_line - 1,
|
|
26
|
+
character: loc.end_column
|
|
27
|
+
)
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -56,9 +56,9 @@ module RubyLsp
|
|
|
56
56
|
Interface::WorkspaceEdit.new(
|
|
57
57
|
changes: {
|
|
58
58
|
@node_context.uri => [
|
|
59
|
-
Interface::TextEdit.new(range: node_to_lsp_range(node), new_text: new_text)
|
|
60
|
-
]
|
|
61
|
-
}
|
|
59
|
+
Interface::TextEdit.new(range: node_to_lsp_range(node), new_text: new_text)
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
62
|
)
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -68,7 +68,7 @@ module RubyLsp
|
|
|
68
68
|
# @return [Interface::WorkspaceEdit]
|
|
69
69
|
def multi_edit_workspace_edit(edits)
|
|
70
70
|
Interface::WorkspaceEdit.new(
|
|
71
|
-
changes: { @node_context.uri => edits }
|
|
71
|
+
changes: { @node_context.uri => edits }
|
|
72
72
|
)
|
|
73
73
|
end
|
|
74
74
|
|
|
@@ -81,10 +81,10 @@ module RubyLsp
|
|
|
81
81
|
line = node.location.start_line - 1
|
|
82
82
|
Interface::TextEdit.new(
|
|
83
83
|
range: Interface::Range.new(
|
|
84
|
-
start: Interface::Position.new(line: line,
|
|
85
|
-
end:
|
|
84
|
+
start: Interface::Position.new(line: line, character: 0),
|
|
85
|
+
end: Interface::Position.new(line: line + 1, character: 0)
|
|
86
86
|
),
|
|
87
|
-
new_text: ""
|
|
87
|
+
new_text: ""
|
|
88
88
|
)
|
|
89
89
|
end
|
|
90
90
|
|
|
@@ -95,6 +95,56 @@ module RubyLsp
|
|
|
95
95
|
def indent_for(node)
|
|
96
96
|
" " * node.location.start_column
|
|
97
97
|
end
|
|
98
|
+
|
|
99
|
+
# Builds a multi-file WorkspaceEdit using document_changes, which
|
|
100
|
+
# supports both file creation (CreateFile) and text edits across
|
|
101
|
+
# multiple documents (TextDocumentEdit).
|
|
102
|
+
#
|
|
103
|
+
# +document_changes+ is an ordered array of:
|
|
104
|
+
# Interface::CreateFile — create a new empty file
|
|
105
|
+
# Interface::TextDocumentEdit — apply text edits to a file
|
|
106
|
+
#
|
|
107
|
+
# The LSP spec requires CreateFile operations to appear before any
|
|
108
|
+
# TextDocumentEdit that writes into the newly created file.
|
|
109
|
+
#
|
|
110
|
+
# @param document_changes [Array<Interface::CreateFile | Interface::TextDocumentEdit>]
|
|
111
|
+
# @return [Interface::WorkspaceEdit]
|
|
112
|
+
def multi_file_workspace_edit(document_changes)
|
|
113
|
+
Interface::WorkspaceEdit.new(document_changes: document_changes)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Builds a TextDocumentEdit for a given URI and array of TextEdits.
|
|
117
|
+
# Used as one entry inside a multi_file_workspace_edit.
|
|
118
|
+
#
|
|
119
|
+
# @param uri [String] file URI, e.g. "file:///project/lib/foo.rb"
|
|
120
|
+
# @param edits [Array<Interface::TextEdit>]
|
|
121
|
+
# @return [Interface::TextDocumentEdit]
|
|
122
|
+
def text_document_edit(uri, edits)
|
|
123
|
+
Interface::TextDocumentEdit.new(
|
|
124
|
+
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
|
|
125
|
+
uri: uri,
|
|
126
|
+
version: nil
|
|
127
|
+
),
|
|
128
|
+
edits: edits
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Builds a CreateFile operation for use in a multi_file_workspace_edit.
|
|
133
|
+
# The file is created empty; a subsequent TextDocumentEdit writes its
|
|
134
|
+
# content.
|
|
135
|
+
#
|
|
136
|
+
# @param uri [String] file URI for the new file
|
|
137
|
+
# @return [Interface::CreateFile]
|
|
138
|
+
def create_file_operation(uri)
|
|
139
|
+
Interface::CreateFile.new(
|
|
140
|
+
kind: "create",
|
|
141
|
+
uri: uri,
|
|
142
|
+
options: Interface::CreateFileOptions.new(
|
|
143
|
+
overwrite: false,
|
|
144
|
+
ignore_if_exists: false
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
end
|
|
98
148
|
end
|
|
99
149
|
end
|
|
100
150
|
end
|
data/lib/ruby_lsp/test_helper.rb
CHANGED
|
@@ -31,16 +31,16 @@ module RubyLsp
|
|
|
31
31
|
uri = URI::Generic.from_path(path: "/test/fixture.rb")
|
|
32
32
|
global_state = RubyLsp::GlobalState.new
|
|
33
33
|
document = RubyLsp::RubyDocument.new(
|
|
34
|
-
source:
|
|
35
|
-
version:
|
|
36
|
-
uri:
|
|
37
|
-
global_state: global_state
|
|
34
|
+
source: source,
|
|
35
|
+
version: 1,
|
|
36
|
+
uri: uri,
|
|
37
|
+
global_state: global_state
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
# LSP range hash — same shape the real server passes to CodeActions.
|
|
41
41
|
range = {
|
|
42
42
|
start: { line: line, character: char },
|
|
43
|
-
end:
|
|
43
|
+
end: { line: line, character: char }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
RubyLsp::Refactor::Addon.refactor_actions_for(document, range)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "ruby_lsp/test_helper"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
class AccessorListenerTest < Minitest::Test
|
|
9
|
+
include RubyLsp::Refactor::TestHelper
|
|
10
|
+
|
|
11
|
+
def find_action(actions, title_pattern)
|
|
12
|
+
actions.find { |a| a.title.match?(title_pattern) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_collapses_attr_reader_and_writer_into_attr_accessor
|
|
16
|
+
source = <<~RUBY
|
|
17
|
+
class User
|
|
18
|
+
attr_reader :name
|
|
19
|
+
|
|
20
|
+
def name=(val)
|
|
21
|
+
@name = val
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
RUBY
|
|
25
|
+
|
|
26
|
+
# Cursor on the attr_reader line
|
|
27
|
+
actions = code_actions_for(source, line: 1)
|
|
28
|
+
action = find_action(actions, /attr_accessor :name/)
|
|
29
|
+
refute_nil action
|
|
30
|
+
|
|
31
|
+
edits = action.edit.changes.values.flatten
|
|
32
|
+
assert_equal 2, edits.size
|
|
33
|
+
|
|
34
|
+
replace_edit = edits.find { |e| e.new_text.include?("attr_accessor") }
|
|
35
|
+
delete_edit = edits.find { |e| e.new_text == "" }
|
|
36
|
+
|
|
37
|
+
refute_nil replace_edit
|
|
38
|
+
refute_nil delete_edit
|
|
39
|
+
assert_match(/attr_accessor :name/, replace_edit.new_text)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_offers_action_from_writer_def_line_too
|
|
43
|
+
source = <<~RUBY
|
|
44
|
+
class User
|
|
45
|
+
attr_reader :name
|
|
46
|
+
|
|
47
|
+
def name=(val)
|
|
48
|
+
@name = val
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
RUBY
|
|
52
|
+
|
|
53
|
+
# Cursor on the def name= line
|
|
54
|
+
actions = code_actions_for(source, line: 3)
|
|
55
|
+
action = find_action(actions, /attr_accessor :name/)
|
|
56
|
+
refute_nil action
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_does_not_offer_when_no_matching_writer
|
|
60
|
+
source = <<~RUBY
|
|
61
|
+
class User
|
|
62
|
+
attr_reader :name
|
|
63
|
+
end
|
|
64
|
+
RUBY
|
|
65
|
+
|
|
66
|
+
actions = code_actions_for(source, line: 1)
|
|
67
|
+
assert_nil find_action(actions, /attr_accessor/)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_does_not_offer_for_non_canonical_writer
|
|
71
|
+
# Writer has extra logic — not a simple passthrough
|
|
72
|
+
source = <<~RUBY
|
|
73
|
+
class User
|
|
74
|
+
attr_reader :name
|
|
75
|
+
|
|
76
|
+
def name=(val)
|
|
77
|
+
@name = val.strip
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
RUBY
|
|
81
|
+
|
|
82
|
+
actions = code_actions_for(source, line: 1)
|
|
83
|
+
assert_nil find_action(actions, /attr_accessor/)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_does_not_raise_on_empty_source
|
|
87
|
+
assert_silent { code_actions_for("", line: 0) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "ruby_lsp/test_helper"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
class ConstantListenerTest < Minitest::Test
|
|
9
|
+
include RubyLsp::Refactor::TestHelper
|
|
10
|
+
|
|
11
|
+
def find_action(actions, title)
|
|
12
|
+
actions.find { |a| a.title == title }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_extracts_integer_literal_to_constant
|
|
16
|
+
source = <<~RUBY
|
|
17
|
+
class Processor
|
|
18
|
+
def run
|
|
19
|
+
items.first(100)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
RUBY
|
|
23
|
+
|
|
24
|
+
actions = code_actions_for(source, line: 2)
|
|
25
|
+
action = find_action(actions, "Extract constant")
|
|
26
|
+
refute_nil action
|
|
27
|
+
|
|
28
|
+
edits = action.edit.changes.values.flatten
|
|
29
|
+
assert_equal 2, edits.size
|
|
30
|
+
|
|
31
|
+
insert_edit = edits.find { |e| e.new_text.include?("EXTRACTED_CONSTANT") && e.new_text.include?("=") }
|
|
32
|
+
replace_edit = edits.find { |e| e.new_text == "EXTRACTED_CONSTANT" }
|
|
33
|
+
|
|
34
|
+
refute_nil insert_edit
|
|
35
|
+
refute_nil replace_edit
|
|
36
|
+
assert_match(/EXTRACTED_CONSTANT = 100/, insert_edit.new_text)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_extracts_string_literal_to_constant
|
|
40
|
+
source = <<~RUBY
|
|
41
|
+
class Mailer
|
|
42
|
+
def subject
|
|
43
|
+
"Welcome to the app"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
RUBY
|
|
47
|
+
|
|
48
|
+
actions = code_actions_for(source, line: 2)
|
|
49
|
+
action = find_action(actions, "Extract constant")
|
|
50
|
+
refute_nil action
|
|
51
|
+
|
|
52
|
+
edits = action.edit.changes.values.flatten
|
|
53
|
+
insert_edit = edits.find { |e| e.new_text.include?("EXTRACTED_CONSTANT") && e.new_text.include?("=") }
|
|
54
|
+
assert_match(/EXTRACTED_CONSTANT = "Welcome to the app"/, insert_edit.new_text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_does_not_offer_outside_class
|
|
58
|
+
source = "items.first(100)\n"
|
|
59
|
+
actions = code_actions_for(source, line: 0)
|
|
60
|
+
assert_nil find_action(actions, "Extract constant")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_does_not_raise_on_empty_source
|
|
64
|
+
assert_silent { code_actions_for("", line: 0) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "ruby_lsp/test_helper"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
class EarlyReturnListenerTest < Minitest::Test
|
|
9
|
+
include RubyLsp::Refactor::TestHelper
|
|
10
|
+
|
|
11
|
+
def find_action(actions, title)
|
|
12
|
+
actions.find { |a| a.title == title }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def single_edit(action)
|
|
16
|
+
edits = action.edit.changes.values.flatten
|
|
17
|
+
assert_equal 1, edits.size
|
|
18
|
+
edits.first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ── core acceptance ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def test_converts_guard_if_to_early_return
|
|
24
|
+
source = <<~RUBY
|
|
25
|
+
def charge_purchase(order)
|
|
26
|
+
if order.fulfilled?
|
|
27
|
+
OrderChargeConfirmation.new(order).create!
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
RUBY
|
|
31
|
+
|
|
32
|
+
actions = code_actions_for(source, line: 1)
|
|
33
|
+
action = find_action(actions, "Convert to early return")
|
|
34
|
+
refute_nil action
|
|
35
|
+
|
|
36
|
+
edit = single_edit(action)
|
|
37
|
+
assert_match(/return unless order\.fulfilled\?/, edit.new_text)
|
|
38
|
+
assert_match(/OrderChargeConfirmation\.new\(order\)\.create!/, edit.new_text)
|
|
39
|
+
refute_match(/\bif\b/, edit.new_text)
|
|
40
|
+
refute_match(/\bend\b/, edit.new_text)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_preserves_multi_statement_body
|
|
44
|
+
source = <<~RUBY
|
|
45
|
+
def process(order)
|
|
46
|
+
if order.valid?
|
|
47
|
+
order.charge!
|
|
48
|
+
order.notify!
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
RUBY
|
|
52
|
+
|
|
53
|
+
actions = code_actions_for(source, line: 1)
|
|
54
|
+
action = find_action(actions, "Convert to early return")
|
|
55
|
+
refute_nil action
|
|
56
|
+
|
|
57
|
+
edit = single_edit(action)
|
|
58
|
+
assert_match(/return unless order\.valid\?/, edit.new_text)
|
|
59
|
+
assert_match(/order\.charge!/, edit.new_text)
|
|
60
|
+
assert_match(/order\.notify!/, edit.new_text)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_preserves_indentation
|
|
64
|
+
source = <<~RUBY
|
|
65
|
+
class Service
|
|
66
|
+
def call(user)
|
|
67
|
+
if user.active?
|
|
68
|
+
user.run!
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
RUBY
|
|
73
|
+
|
|
74
|
+
actions = code_actions_for(source, line: 2)
|
|
75
|
+
action = find_action(actions, "Convert to early return")
|
|
76
|
+
refute_nil action
|
|
77
|
+
|
|
78
|
+
edit = single_edit(action)
|
|
79
|
+
# The edit replaces the if node range (start_column=4), so new_text
|
|
80
|
+
# begins with the node's own indentation (4 spaces).
|
|
81
|
+
assert_match(/\A return unless user\.active\?/, edit.new_text)
|
|
82
|
+
assert_match(/user\.run!/, edit.new_text)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ── negative cases ─────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def test_does_not_offer_when_if_has_else
|
|
88
|
+
source = <<~RUBY
|
|
89
|
+
def process(order)
|
|
90
|
+
if order.valid?
|
|
91
|
+
order.charge!
|
|
92
|
+
else
|
|
93
|
+
order.reject!
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
RUBY
|
|
97
|
+
|
|
98
|
+
actions = code_actions_for(source, line: 1)
|
|
99
|
+
assert_nil find_action(actions, "Convert to early return")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_does_not_offer_when_if_has_elsif
|
|
103
|
+
source = <<~RUBY
|
|
104
|
+
def process(order)
|
|
105
|
+
if order.paid?
|
|
106
|
+
order.complete!
|
|
107
|
+
elsif order.pending?
|
|
108
|
+
order.charge!
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
RUBY
|
|
112
|
+
|
|
113
|
+
actions = code_actions_for(source, line: 1)
|
|
114
|
+
assert_nil find_action(actions, "Convert to early return")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_does_not_offer_when_if_is_not_first_statement
|
|
118
|
+
source = <<~RUBY
|
|
119
|
+
def process(order)
|
|
120
|
+
order.validate!
|
|
121
|
+
if order.valid?
|
|
122
|
+
order.charge!
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
RUBY
|
|
126
|
+
|
|
127
|
+
# Cursor on the if (line 2) — it is not the first statement
|
|
128
|
+
actions = code_actions_for(source, line: 2)
|
|
129
|
+
assert_nil find_action(actions, "Convert to early return")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def test_does_not_offer_outside_a_method
|
|
133
|
+
source = <<~RUBY
|
|
134
|
+
if user.active?
|
|
135
|
+
user.run!
|
|
136
|
+
end
|
|
137
|
+
RUBY
|
|
138
|
+
|
|
139
|
+
actions = code_actions_for(source, line: 0)
|
|
140
|
+
assert_nil find_action(actions, "Convert to early return")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def test_does_not_offer_for_post_conditional
|
|
144
|
+
source = "user.run! if user.active?\n"
|
|
145
|
+
actions = code_actions_for(source, line: 0)
|
|
146
|
+
assert_nil find_action(actions, "Convert to early return")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── resilience ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def test_does_not_raise_on_empty_source
|
|
152
|
+
assert_silent { code_actions_for("", line: 0) }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "ruby_lsp/test_helper"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
class EnumerableListenerTest < Minitest::Test
|
|
9
|
+
include RubyLsp::Refactor::TestHelper
|
|
10
|
+
|
|
11
|
+
def find_action(actions, title)
|
|
12
|
+
actions.find { |a| a.title == title }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def single_edit(action)
|
|
16
|
+
edits = action.edit.changes.values.flatten
|
|
17
|
+
assert_equal 1, edits.size
|
|
18
|
+
edits.first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ── map + flatten → flat_map ───────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def test_converts_map_flatten_1_to_flat_map
|
|
24
|
+
source = "items.map { |i| i.tags }.flatten(1)\n"
|
|
25
|
+
actions = code_actions_for(source, line: 0)
|
|
26
|
+
action = find_action(actions, "Convert to .flat_map")
|
|
27
|
+
refute_nil action
|
|
28
|
+
|
|
29
|
+
edit = single_edit(action)
|
|
30
|
+
assert_equal "items.flat_map { |i| i.tags }", edit.new_text.strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_converts_map_flatten_no_arg_to_flat_map
|
|
34
|
+
source = "items.map { |i| i.tags }.flatten\n"
|
|
35
|
+
actions = code_actions_for(source, line: 0)
|
|
36
|
+
action = find_action(actions, "Convert to .flat_map")
|
|
37
|
+
refute_nil action
|
|
38
|
+
|
|
39
|
+
edit = single_edit(action)
|
|
40
|
+
assert_equal "items.flat_map { |i| i.tags }", edit.new_text.strip
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_does_not_offer_flat_map_for_flatten_2
|
|
44
|
+
source = "items.map { |i| i.tags }.flatten(2)\n"
|
|
45
|
+
actions = code_actions_for(source, line: 0)
|
|
46
|
+
assert_nil find_action(actions, "Convert to .flat_map")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ── select + first → find ─────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def test_converts_select_first_to_find
|
|
52
|
+
source = "users.select { |u| u.admin? }.first\n"
|
|
53
|
+
actions = code_actions_for(source, line: 0)
|
|
54
|
+
action = find_action(actions, "Convert to .find")
|
|
55
|
+
refute_nil action
|
|
56
|
+
|
|
57
|
+
edit = single_edit(action)
|
|
58
|
+
assert_equal "users.find { |u| u.admin? }", edit.new_text.strip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── map + compact → filter_map ────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def test_converts_map_compact_to_filter_map
|
|
64
|
+
source = "items.map { |i| i.value }.compact\n"
|
|
65
|
+
actions = code_actions_for(source, line: 0)
|
|
66
|
+
action = find_action(actions, "Convert to .filter_map")
|
|
67
|
+
refute_nil action
|
|
68
|
+
|
|
69
|
+
edit = single_edit(action)
|
|
70
|
+
assert_equal "items.filter_map { |i| i.value }", edit.new_text.strip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── resilience ─────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def test_does_not_raise_on_empty_source
|
|
76
|
+
assert_silent { code_actions_for("", line: 0) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|