ruby-lsp-refactor 0.1.0

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.
@@ -0,0 +1,307 @@
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 ConditionalListenerTest < Minitest::Test
9
+ include RubyLsp::Refactor::TestHelper
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Helpers
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def find_action(actions, title)
16
+ actions.find { |a| a.title == title }
17
+ end
18
+
19
+ def single_edit(action)
20
+ edits = action.edit.changes.values.flatten
21
+ assert_equal 1, edits.size, "Expected exactly one TextEdit, got #{edits.size}"
22
+ edits.first
23
+ end
24
+
25
+ # ===========================================================================
26
+ # 1. Convert to post-conditional (block if → post-if)
27
+ # ===========================================================================
28
+
29
+ def test_converts_simple_if_block_to_post_conditional
30
+ source = <<~RUBY
31
+ if user.qualified?
32
+ user.approve!
33
+ end
34
+ RUBY
35
+
36
+ actions = code_actions_for(source, line: 0)
37
+ action = find_action(actions, "Convert to post-conditional")
38
+ refute_nil action, "Expected a 'Convert to post-conditional' code action"
39
+
40
+ edit = single_edit(action)
41
+ assert_equal "user.approve! if user.qualified?", edit.new_text.strip
42
+ end
43
+
44
+ def test_does_not_offer_post_conditional_for_if_with_else
45
+ source = <<~RUBY
46
+ if user.qualified?
47
+ user.approve!
48
+ else
49
+ user.reject!
50
+ end
51
+ RUBY
52
+
53
+ actions = code_actions_for(source, line: 0)
54
+ assert_nil find_action(actions, "Convert to post-conditional")
55
+ end
56
+
57
+ def test_does_not_offer_post_conditional_for_if_with_elsif
58
+ source = <<~RUBY
59
+ if user.admin?
60
+ user.grant_admin!
61
+ elsif user.qualified?
62
+ user.approve!
63
+ end
64
+ RUBY
65
+
66
+ actions = code_actions_for(source, line: 0)
67
+ assert_nil find_action(actions, "Convert to post-conditional")
68
+ end
69
+
70
+ def test_does_not_offer_post_conditional_for_multi_statement_body
71
+ source = <<~RUBY
72
+ if user.qualified?
73
+ user.approve!
74
+ notify(user)
75
+ end
76
+ RUBY
77
+
78
+ actions = code_actions_for(source, line: 0)
79
+ assert_nil find_action(actions, "Convert to post-conditional")
80
+ end
81
+
82
+ def test_post_conditional_preserves_leading_indentation
83
+ source = <<~RUBY
84
+ def process
85
+ if user.qualified?
86
+ user.approve!
87
+ end
88
+ end
89
+ RUBY
90
+
91
+ actions = code_actions_for(source, line: 1)
92
+ action = find_action(actions, "Convert to post-conditional")
93
+ refute_nil action
94
+
95
+ edit = single_edit(action)
96
+ assert_match(/\A user\.approve! if user\.qualified\?/, edit.new_text)
97
+ end
98
+
99
+ def test_post_conditional_edit_range_covers_entire_if_block
100
+ source = <<~RUBY
101
+ if user.qualified?
102
+ user.approve!
103
+ end
104
+ RUBY
105
+
106
+ actions = code_actions_for(source, line: 0)
107
+ edit = single_edit(find_action(actions, "Convert to post-conditional"))
108
+
109
+ assert_equal 0, edit.range.start.line
110
+ assert_equal 2, edit.range.end.line
111
+ end
112
+
113
+ def test_converts_unless_block_to_post_conditional
114
+ source = <<~RUBY
115
+ unless user.banned?
116
+ user.login!
117
+ end
118
+ RUBY
119
+
120
+ actions = code_actions_for(source, line: 0)
121
+ action = find_action(actions, "Convert to post-conditional")
122
+ refute_nil action
123
+
124
+ edit = single_edit(action)
125
+ assert_equal "user.login! unless user.banned?", edit.new_text.strip
126
+ end
127
+
128
+ # ===========================================================================
129
+ # 2. Convert to block if (post-if → block if)
130
+ # ===========================================================================
131
+
132
+ def test_converts_post_conditional_to_block_if
133
+ source = "user.approve! if user.qualified?\n"
134
+
135
+ actions = code_actions_for(source, line: 0)
136
+ action = find_action(actions, "Convert to block if")
137
+ refute_nil action, "Expected a 'Convert to block if' code action"
138
+
139
+ edit = single_edit(action)
140
+ assert_match(/if user\.qualified\?/, edit.new_text)
141
+ assert_match(/user\.approve!/, edit.new_text)
142
+ assert_match(/end/, edit.new_text)
143
+ end
144
+
145
+ def test_converts_post_conditional_unless_to_block_unless
146
+ source = "user.login! unless user.banned?\n"
147
+
148
+ actions = code_actions_for(source, line: 0)
149
+ action = find_action(actions, "Convert to block unless")
150
+ refute_nil action
151
+
152
+ edit = single_edit(action)
153
+ assert_match(/unless user\.banned\?/, edit.new_text)
154
+ assert_match(/user\.login!/, edit.new_text)
155
+ assert_match(/end/, edit.new_text)
156
+ end
157
+
158
+ # ===========================================================================
159
+ # 3. Toggle if ↔ unless
160
+ # ===========================================================================
161
+
162
+ def test_toggles_if_to_unless
163
+ source = <<~RUBY
164
+ if user.active?
165
+ user.greet!
166
+ end
167
+ RUBY
168
+
169
+ actions = code_actions_for(source, line: 0)
170
+ action = find_action(actions, "Convert to unless")
171
+ refute_nil action
172
+
173
+ edit = single_edit(action)
174
+ assert_match(/unless user\.active\?/, edit.new_text)
175
+ assert_match(/user\.greet!/, edit.new_text)
176
+ end
177
+
178
+ def test_toggles_unless_to_if
179
+ source = <<~RUBY
180
+ unless user.banned?
181
+ user.login!
182
+ end
183
+ RUBY
184
+
185
+ actions = code_actions_for(source, line: 0)
186
+ action = find_action(actions, "Convert to if")
187
+ refute_nil action
188
+
189
+ edit = single_edit(action)
190
+ assert_match(/if user\.banned\?/, edit.new_text)
191
+ assert_match(/user\.login!/, edit.new_text)
192
+ end
193
+
194
+ def test_toggle_if_with_bang_predicate_strips_negation
195
+ source = <<~RUBY
196
+ if !user.banned?
197
+ user.login!
198
+ end
199
+ RUBY
200
+
201
+ actions = code_actions_for(source, line: 0)
202
+ action = find_action(actions, "Convert to unless")
203
+ refute_nil action
204
+
205
+ edit = single_edit(action)
206
+ # The `!` should be stripped: `unless user.banned?`
207
+ assert_match(/unless user\.banned\?/, edit.new_text)
208
+ refute_match(/!user/, edit.new_text)
209
+ end
210
+
211
+ def test_does_not_offer_toggle_when_else_present
212
+ source = <<~RUBY
213
+ if user.active?
214
+ greet!
215
+ else
216
+ reject!
217
+ end
218
+ RUBY
219
+
220
+ # Toggle should not appear; invert should appear instead.
221
+ actions = code_actions_for(source, line: 0)
222
+ assert_nil find_action(actions, "Convert to unless")
223
+ end
224
+
225
+ # ===========================================================================
226
+ # 4. Invert if/else
227
+ # ===========================================================================
228
+
229
+ def test_inverts_if_else_branches
230
+ source = <<~RUBY
231
+ if user.admin?
232
+ grant!
233
+ else
234
+ deny!
235
+ end
236
+ RUBY
237
+
238
+ actions = code_actions_for(source, line: 0)
239
+ action = find_action(actions, "Invert if/else")
240
+ refute_nil action
241
+
242
+ edit = single_edit(action)
243
+ # Predicate should be negated and branches swapped.
244
+ assert_match(/if !user\.admin\?/, edit.new_text)
245
+ # deny! should now be in the then-branch (first after if)
246
+ deny_pos = edit.new_text.index("deny!")
247
+ grant_pos = edit.new_text.index("grant!")
248
+ assert deny_pos < grant_pos, "deny! should appear before grant! after inversion"
249
+ end
250
+
251
+ def test_invert_cancels_double_negation
252
+ source = <<~RUBY
253
+ if !user.banned?
254
+ allow!
255
+ else
256
+ block!
257
+ end
258
+ RUBY
259
+
260
+ actions = code_actions_for(source, line: 0)
261
+ action = find_action(actions, "Invert if/else")
262
+ refute_nil action
263
+
264
+ edit = single_edit(action)
265
+ # `!(!user.banned?)` should simplify to `user.banned?`
266
+ assert_match(/if user\.banned\?/, edit.new_text)
267
+ refute_match(/!!/, edit.new_text)
268
+ end
269
+
270
+ def test_does_not_offer_invert_without_else
271
+ source = <<~RUBY
272
+ if user.active?
273
+ greet!
274
+ end
275
+ RUBY
276
+
277
+ actions = code_actions_for(source, line: 0)
278
+ assert_nil find_action(actions, "Invert if/else")
279
+ end
280
+
281
+ # ===========================================================================
282
+ # Resilience
283
+ # ===========================================================================
284
+
285
+ def test_does_not_raise_on_empty_source
286
+ assert_silent { code_actions_for("", line: 0) }
287
+ end
288
+
289
+ def test_does_not_raise_on_incomplete_if
290
+ source = "if user.qualified?\n"
291
+ assert_silent { code_actions_for(source, line: 0) }
292
+ end
293
+
294
+ def test_does_not_offer_action_when_cursor_is_outside_node
295
+ source = <<~RUBY
296
+ if user.qualified?
297
+ user.approve!
298
+ end
299
+ puts "done"
300
+ RUBY
301
+
302
+ actions = code_actions_for(source, line: 3)
303
+ assert_nil find_action(actions, "Convert to post-conditional")
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,72 @@
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 HashListenerTest < 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
+ # ---------------------------------------------------------------------------
22
+ # Core acceptance
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def test_converts_hash_rocket_to_keyword_syntax
26
+ source = "{ :foo => 1, :bar => 2 }\n"
27
+ actions = code_actions_for(source, line: 0)
28
+ action = find_action(actions, "Convert to keyword syntax")
29
+ refute_nil action
30
+
31
+ edit = single_edit(action)
32
+ assert_equal "{ foo: 1, bar: 2 }", edit.new_text
33
+ end
34
+
35
+ def test_converts_mixed_hash_only_rocket_pairs
36
+ # String key should be left verbatim; symbol rocket key should be converted.
37
+ source = %({ "name" => "Alice", :age => 30 }\n)
38
+ actions = code_actions_for(source, line: 0)
39
+ action = find_action(actions, "Convert to keyword syntax")
40
+ refute_nil action
41
+
42
+ edit = single_edit(action)
43
+ assert_match(/age: 30/, edit.new_text)
44
+ assert_match(/"name" => "Alice"/, edit.new_text)
45
+ end
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Negative cases
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def test_does_not_offer_action_for_already_keyword_hash
52
+ source = "{ foo: 1, bar: 2 }\n"
53
+ actions = code_actions_for(source, line: 0)
54
+ assert_nil find_action(actions, "Convert to keyword syntax")
55
+ end
56
+
57
+ def test_does_not_offer_action_for_empty_hash
58
+ source = "{}\n"
59
+ actions = code_actions_for(source, line: 0)
60
+ assert_nil find_action(actions, "Convert to keyword syntax")
61
+ end
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Resilience
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def test_does_not_raise_on_empty_source
68
+ assert_silent { code_actions_for("", line: 0) }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,193 @@
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 MethodListenerTest < 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 all_edits(action)
16
+ action.edit.changes.values.flatten
17
+ end
18
+
19
+ # ===========================================================================
20
+ # 1. Extract to method
21
+ # ===========================================================================
22
+
23
+ def test_extract_to_method_replaces_rhs_and_inserts_new_method
24
+ source = <<~RUBY
25
+ def process
26
+ result = expensive_computation
27
+ end
28
+ RUBY
29
+
30
+ actions = code_actions_for(source, line: 1)
31
+ action = find_action(actions, /Extract to method/)
32
+ refute_nil action
33
+
34
+ edits = all_edits(action)
35
+ assert_equal 2, edits.size
36
+
37
+ replace_edit = edits.find { |e| e.new_text.include?("result") && !e.new_text.include?("def") }
38
+ insert_edit = edits.find { |e| e.new_text.include?("def result") }
39
+
40
+ refute_nil replace_edit, "Expected an edit replacing the RHS with a method call"
41
+ refute_nil insert_edit, "Expected an edit inserting the new method definition"
42
+
43
+ assert_match(/def result/, insert_edit.new_text)
44
+ assert_match(/expensive_computation/, insert_edit.new_text)
45
+ end
46
+
47
+ def test_extract_to_method_passes_outer_variables_as_params
48
+ source = <<~RUBY
49
+ def process(data)
50
+ threshold = 10
51
+ result = data.select { |x| x > threshold }
52
+ end
53
+ RUBY
54
+
55
+ # Cursor on the `result =` line (line 2).
56
+ actions = code_actions_for(source, line: 2)
57
+ action = find_action(actions, /Extract to method/)
58
+ refute_nil action
59
+
60
+ edits = all_edits(action)
61
+ insert_edit = edits.find { |e| e.new_text.include?("def result") }
62
+ refute_nil insert_edit
63
+
64
+ # `threshold` was defined before the extraction point and is used in the RHS.
65
+ assert_match(/def result\(threshold\)/, insert_edit.new_text)
66
+ end
67
+
68
+ # ===========================================================================
69
+ # 2. Add parameter
70
+ # ===========================================================================
71
+
72
+ def test_add_parameter_appends_to_existing_params
73
+ source = <<~RUBY
74
+ def greet(name)
75
+ puts name
76
+ end
77
+ RUBY
78
+
79
+ actions = code_actions_for(source, line: 0)
80
+ action = find_action(actions, "Add parameter")
81
+ refute_nil action
82
+
83
+ edits = all_edits(action)
84
+ assert_equal 1, edits.size
85
+
86
+ edit = edits.first
87
+ assert_equal ", new_param", edit.new_text
88
+ end
89
+
90
+ def test_add_parameter_creates_parens_when_no_params
91
+ source = <<~RUBY
92
+ def greet
93
+ puts "hello"
94
+ end
95
+ RUBY
96
+
97
+ actions = code_actions_for(source, line: 0)
98
+ action = find_action(actions, "Add parameter")
99
+ refute_nil action
100
+
101
+ edits = all_edits(action)
102
+ assert_equal 1, edits.size
103
+
104
+ edit = edits.first
105
+ assert_equal "(new_param)", edit.new_text
106
+ end
107
+
108
+ # ===========================================================================
109
+ # 3. Convert to keyword arguments
110
+ # ===========================================================================
111
+
112
+ def test_converts_positional_params_to_kwargs
113
+ source = <<~RUBY
114
+ def create(name, age)
115
+ User.new(name, age)
116
+ end
117
+ RUBY
118
+
119
+ actions = code_actions_for(source, line: 0)
120
+ action = find_action(actions, "Convert to keyword arguments")
121
+ refute_nil action
122
+
123
+ edits = all_edits(action)
124
+ assert_equal 1, edits.size
125
+
126
+ edit = edits.first
127
+ assert_equal "name:, age:", edit.new_text
128
+ end
129
+
130
+ def test_does_not_offer_kwargs_conversion_when_no_positional_params
131
+ source = <<~RUBY
132
+ def greet
133
+ puts "hello"
134
+ end
135
+ RUBY
136
+
137
+ actions = code_actions_for(source, line: 0)
138
+ assert_nil find_action(actions, "Convert to keyword arguments")
139
+ end
140
+
141
+ # ===========================================================================
142
+ # 4. Extract to let (RSpec)
143
+ # ===========================================================================
144
+
145
+ def test_extract_to_let_inserts_let_block_and_removes_assignment
146
+ source = <<~RUBY
147
+ describe "User" do
148
+ it "logs in" do
149
+ user = User.new(name: "Alice")
150
+ expect(user.name).to eq("Alice")
151
+ end
152
+ end
153
+ RUBY
154
+
155
+ # Cursor on the `user =` line (line 2).
156
+ actions = code_actions_for(source, line: 2)
157
+ action = find_action(actions, /Extract to let/)
158
+ refute_nil action
159
+
160
+ edits = all_edits(action)
161
+ assert_equal 2, edits.size
162
+
163
+ insert_edit = edits.find { |e| e.new_text.include?("let(") }
164
+ delete_edit = edits.find { |e| e.new_text == "" }
165
+
166
+ refute_nil insert_edit, "Expected an edit inserting a let block"
167
+ refute_nil delete_edit, "Expected an edit deleting the original assignment"
168
+
169
+ assert_match(/let\(:user\)/, insert_edit.new_text)
170
+ assert_match(/User\.new\(name: "Alice"\)/, insert_edit.new_text)
171
+ end
172
+
173
+ def test_does_not_offer_extract_to_let_outside_rspec_example
174
+ source = <<~RUBY
175
+ def setup
176
+ user = User.new
177
+ end
178
+ RUBY
179
+
180
+ actions = code_actions_for(source, line: 1)
181
+ assert_nil find_action(actions, /Extract to let/)
182
+ end
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Resilience
186
+ # ---------------------------------------------------------------------------
187
+
188
+ def test_does_not_raise_on_empty_source
189
+ assert_silent { code_actions_for("", line: 0) }
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,70 @@
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 StringListenerTest < 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
+ # ---------------------------------------------------------------------------
22
+ # Core acceptance
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def test_converts_single_quoted_string_to_double_quoted
26
+ source = "'hello world'\n"
27
+ actions = code_actions_for(source, line: 0)
28
+ action = find_action(actions, "Convert to interpolated string")
29
+ refute_nil action
30
+
31
+ edit = single_edit(action)
32
+ assert_equal '"hello world"', edit.new_text
33
+ end
34
+
35
+ def test_escapes_embedded_double_quotes
36
+ source = %('say "hi"' \n)
37
+ actions = code_actions_for(source, line: 0)
38
+ action = find_action(actions, "Convert to interpolated string")
39
+ refute_nil action
40
+
41
+ edit = single_edit(action)
42
+ assert_equal '"say \"hi\""', edit.new_text
43
+ end
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Negative cases
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def test_does_not_offer_action_for_already_double_quoted_string
50
+ source = '"hello world"' + "\n"
51
+ actions = code_actions_for(source, line: 0)
52
+ assert_nil find_action(actions, "Convert to interpolated string")
53
+ end
54
+
55
+ def test_does_not_offer_action_for_interpolated_string
56
+ source = '"hello #{name}"' + "\n"
57
+ actions = code_actions_for(source, line: 0)
58
+ assert_nil find_action(actions, "Convert to interpolated string")
59
+ end
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Resilience
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def test_does_not_raise_on_empty_source
66
+ assert_silent { code_actions_for("", line: 0) }
67
+ end
68
+ end
69
+ end
70
+ end