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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +7 -7
- data/README.md +553 -115
- data/lib/ruby/lsp/refactor/version.rb +1 -1
- data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +34 -23
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/early_return_listener.rb +105 -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/method_listener.rb +11 -83
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/tap_listener.rb +133 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +2 -44
- data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +50 -0
- data/test/ruby_lsp_refactor/early_return_listener_test.rb +156 -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/method_listener_test.rb +3 -52
- data/test/ruby_lsp_refactor/tap_listener_test.rb +144 -0
- data/test/ruby_lsp_refactor/variable_listener_test.rb +0 -23
- metadata +9 -3
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/block_style_listener.rb +0 -117
- data/test/ruby_lsp_refactor/block_style_listener_test.rb +0 -98
|
@@ -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
|
-
#
|
|
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,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
|