ruby-lsp-refactor 0.1.1 → 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.
@@ -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
@@ -12,11 +12,8 @@ module RubyLsp
12
12
  # result = user.calculate → (line deleted)
13
13
  # puts result → puts user.calculate
14
14
  #
15
- # 2. Extract local variable
16
- # Cursor on any expression; wraps it in a new variable assignment
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,7 +37,6 @@ module RubyLsp
40
37
  self,
41
38
  :on_local_variable_write_node_enter,
42
39
  :on_local_variable_read_node_enter,
43
- :on_call_node_enter,
44
40
  :on_program_node_leave
45
41
  )
46
42
  end
@@ -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) }
@@ -100,35 +87,6 @@ module RubyLsp
100
87
  edit: multi_edit_workspace_edit(edits)
101
88
  )
102
89
  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])
130
- )
131
- end
132
90
  end
133
91
  end
134
92
  end
@@ -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
@@ -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,189 @@
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 ExtractIncludeFileListenerTest < 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
+ # ── core acceptance ────────────────────────────────────────────────────
16
+
17
+ def test_extracts_module_to_new_file
18
+ source = <<~RUBY
19
+ module Greetable
20
+ def greet = "hello"
21
+ end
22
+
23
+ class User
24
+ include Greetable
25
+ end
26
+ RUBY
27
+
28
+ actions = code_actions_for(source, line: 0)
29
+ action = find_action(actions, /Extract to include file/)
30
+ refute_nil action
31
+
32
+ assert_match(/greetable\.rb/, action.title)
33
+
34
+ doc_changes = action.edit.document_changes
35
+ assert_equal 3, doc_changes.length,
36
+ "Expected CreateFile + 2 TextDocumentEdits, got #{doc_changes.length}"
37
+ end
38
+
39
+ def test_create_file_operation_is_first
40
+ source = <<~RUBY
41
+ module Greetable
42
+ def greet = "hello"
43
+ end
44
+
45
+ class User; end
46
+ RUBY
47
+
48
+ actions = code_actions_for(source, line: 0)
49
+ action = find_action(actions, /Extract to include file/)
50
+ refute_nil action
51
+
52
+ create_op = action.edit.document_changes.first
53
+ assert_equal "create", create_op.kind
54
+ assert_match(/greetable\.rb/, create_op.uri)
55
+ end
56
+
57
+ def test_new_file_content_includes_frozen_comment_and_source
58
+ source = <<~RUBY
59
+ module Greetable
60
+ def greet = "hello"
61
+ end
62
+
63
+ class User; end
64
+ RUBY
65
+
66
+ actions = code_actions_for(source, line: 0)
67
+ action = find_action(actions, /Extract to include file/)
68
+ refute_nil action
69
+
70
+ # Second document_change is the TextDocumentEdit writing the new file.
71
+ write_edit = action.edit.document_changes[1]
72
+ content = write_edit.edits.first.new_text
73
+
74
+ assert_match(/# frozen_string_literal: true/, content)
75
+ assert_match(/module Greetable/, content)
76
+ assert_match(/def greet = "hello"/, content)
77
+ end
78
+
79
+ def test_source_file_is_replaced_with_require_relative
80
+ source = <<~RUBY
81
+ module Greetable
82
+ def greet = "hello"
83
+ end
84
+
85
+ class User; end
86
+ RUBY
87
+
88
+ actions = code_actions_for(source, line: 0)
89
+ action = find_action(actions, /Extract to include file/)
90
+ refute_nil action
91
+
92
+ # Third document_change edits the source file.
93
+ source_edit = action.edit.document_changes[2]
94
+ new_text = source_edit.edits.first.new_text
95
+
96
+ assert_match(/require_relative "greetable"/, new_text)
97
+ refute_match(/module Greetable/, new_text)
98
+ end
99
+
100
+ def test_new_file_uri_is_in_same_directory_as_source
101
+ source = <<~RUBY
102
+ module Greetable
103
+ def greet = "hello"
104
+ end
105
+
106
+ class User; end
107
+ RUBY
108
+
109
+ actions = code_actions_for(source, line: 0)
110
+ action = find_action(actions, /Extract to include file/)
111
+ refute_nil action
112
+
113
+ create_op = action.edit.document_changes.first
114
+ source_dir = File.dirname(URI("file:///test/fixture.rb").path)
115
+ expected_path = File.join(source_dir, "greetable.rb")
116
+
117
+ assert_equal "file://#{expected_path}", create_op.uri
118
+ end
119
+
120
+ def test_converts_camel_case_module_name_to_snake_case_filename
121
+ source = <<~RUBY
122
+ module MyHelperModule
123
+ def help = true
124
+ end
125
+
126
+ class App; end
127
+ RUBY
128
+
129
+ actions = code_actions_for(source, line: 0)
130
+ action = find_action(actions, /Extract to include file/)
131
+ refute_nil action
132
+
133
+ assert_match(/my_helper_module\.rb/, action.title)
134
+ create_op = action.edit.document_changes.first
135
+ assert_match(/my_helper_module\.rb/, create_op.uri)
136
+ end
137
+
138
+ def test_works_for_class_nodes_too
139
+ source = <<~RUBY
140
+ class AdminUser
141
+ def admin? = true
142
+ end
143
+
144
+ class User; end
145
+ RUBY
146
+
147
+ actions = code_actions_for(source, line: 0)
148
+ action = find_action(actions, /Extract to include file/)
149
+ refute_nil action
150
+
151
+ assert_match(/admin_user\.rb/, action.title)
152
+ end
153
+
154
+ # ── negative cases ─────────────────────────────────────────────────────
155
+
156
+ def test_does_not_offer_when_module_is_only_top_level_node
157
+ # Nothing else in the file — extraction would leave an empty source.
158
+ source = <<~RUBY
159
+ module Greetable
160
+ def greet = "hello"
161
+ end
162
+ RUBY
163
+
164
+ actions = code_actions_for(source, line: 0)
165
+ assert_nil find_action(actions, /Extract to include file/)
166
+ end
167
+
168
+ def test_does_not_offer_for_nested_module
169
+ source = <<~RUBY
170
+ class User
171
+ module Callbacks
172
+ def before_save = nil
173
+ end
174
+ end
175
+ RUBY
176
+
177
+ # Cursor on the nested module (line 1)
178
+ actions = code_actions_for(source, line: 1)
179
+ assert_nil find_action(actions, /Extract to include file/)
180
+ end
181
+
182
+ # ── resilience ─────────────────────────────────────────────────────────
183
+
184
+ def test_does_not_raise_on_empty_source
185
+ assert_silent { code_actions_for("", line: 0) }
186
+ end
187
+ end
188
+ end
189
+ end