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
|
@@ -2,27 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
require "ruby_lsp/addon"
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# Conditionals
|
|
6
6
|
require_relative "listeners/conditional_listener"
|
|
7
|
-
require_relative "listeners/
|
|
8
|
-
require_relative "listeners/block_style_listener"
|
|
9
|
-
require_relative "listeners/logical_operator_listener"
|
|
7
|
+
require_relative "listeners/early_return_listener"
|
|
10
8
|
|
|
11
|
-
#
|
|
12
|
-
require_relative "listeners/
|
|
13
|
-
require_relative "listeners/hash_listener"
|
|
14
|
-
require_relative "listeners/array_listener"
|
|
9
|
+
# Strings
|
|
10
|
+
require_relative "listeners/string_listener"
|
|
15
11
|
require_relative "listeners/string_array_listener"
|
|
16
12
|
require_relative "listeners/string_freeze_listener"
|
|
13
|
+
|
|
14
|
+
# Collections
|
|
15
|
+
require_relative "listeners/array_listener"
|
|
16
|
+
require_relative "listeners/hash_listener"
|
|
17
17
|
require_relative "listeners/enumerable_listener"
|
|
18
|
-
require_relative "listeners/raise_listener"
|
|
19
18
|
|
|
20
|
-
#
|
|
21
|
-
require_relative "listeners/
|
|
19
|
+
# Variables & constants
|
|
20
|
+
require_relative "listeners/variable_listener"
|
|
22
21
|
require_relative "listeners/constant_listener"
|
|
22
|
+
|
|
23
|
+
# Methods & classes
|
|
24
|
+
require_relative "listeners/method_listener"
|
|
25
|
+
require_relative "listeners/extract_predicate_listener"
|
|
23
26
|
require_relative "listeners/accessor_listener"
|
|
24
27
|
require_relative "listeners/rescue_listener"
|
|
25
28
|
require_relative "listeners/super_listener"
|
|
29
|
+
|
|
30
|
+
# Multi-file
|
|
31
|
+
require_relative "listeners/extract_include_file_listener"
|
|
32
|
+
|
|
33
|
+
# Operators & blocks
|
|
34
|
+
require_relative "listeners/tap_listener"
|
|
35
|
+
require_relative "listeners/logical_operator_listener"
|
|
36
|
+
require_relative "listeners/raise_listener"
|
|
37
|
+
|
|
38
|
+
# RSpec
|
|
26
39
|
require_relative "listeners/rspec_let_listener"
|
|
27
40
|
|
|
28
41
|
module RubyLsp
|
|
@@ -90,27 +103,25 @@ module RubyLsp
|
|
|
90
103
|
response_builder = RubyLsp::ResponseBuilders::CollectionResponseBuilder.new
|
|
91
104
|
dispatcher = Prism::Dispatcher.new
|
|
92
105
|
|
|
93
|
-
# Phase 1 – Local rewrites
|
|
94
106
|
ConditionalListener.new(response_builder, node_context, dispatcher)
|
|
107
|
+
EarlyReturnListener.new(response_builder, node_context, dispatcher)
|
|
95
108
|
StringListener.new(response_builder, node_context, dispatcher)
|
|
96
|
-
BlockStyleListener.new(response_builder, node_context, dispatcher)
|
|
97
|
-
LogicalOperatorListener.new(response_builder, node_context, dispatcher)
|
|
98
|
-
|
|
99
|
-
# Phase 2 – Variable & literal optimisation
|
|
100
|
-
VariableListener.new(response_builder, node_context, dispatcher)
|
|
101
|
-
HashListener.new(response_builder, node_context, dispatcher)
|
|
102
|
-
ArrayListener.new(response_builder, node_context, dispatcher)
|
|
103
109
|
StringArrayListener.new(response_builder, node_context, dispatcher)
|
|
104
110
|
StringFreezeListener.new(response_builder, node_context, dispatcher)
|
|
111
|
+
ArrayListener.new(response_builder, node_context, dispatcher)
|
|
112
|
+
HashListener.new(response_builder, node_context, dispatcher)
|
|
105
113
|
EnumerableListener.new(response_builder, node_context, dispatcher)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# Phase 3 – Advanced structure
|
|
109
|
-
MethodListener.new(response_builder, node_context, dispatcher)
|
|
114
|
+
VariableListener.new(response_builder, node_context, dispatcher)
|
|
110
115
|
ConstantListener.new(response_builder, node_context, dispatcher)
|
|
116
|
+
MethodListener.new(response_builder, node_context, dispatcher)
|
|
117
|
+
ExtractPredicateListener.new(response_builder, node_context, dispatcher)
|
|
111
118
|
AccessorListener.new(response_builder, node_context, dispatcher)
|
|
112
119
|
RescueListener.new(response_builder, node_context, dispatcher)
|
|
113
120
|
SuperListener.new(response_builder, node_context, dispatcher)
|
|
121
|
+
ExtractIncludeFileListener.new(response_builder, node_context, dispatcher)
|
|
122
|
+
TapListener.new(response_builder, node_context, dispatcher)
|
|
123
|
+
LogicalOperatorListener.new(response_builder, node_context, dispatcher)
|
|
124
|
+
RaiseListener.new(response_builder, node_context, dispatcher)
|
|
114
125
|
RspecLetListener.new(response_builder, node_context, dispatcher)
|
|
115
126
|
|
|
116
127
|
dispatcher.dispatch(document.ast)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Offers "Convert to early return" when the cursor is on a guard `if` block
|
|
8
|
+
# that is the first statement in a method body and has no `else` branch.
|
|
9
|
+
#
|
|
10
|
+
# From "Prefer early returns" in Refactoring Rails: replace a top-level
|
|
11
|
+
# guard `if` with `return unless` so the happy path is not nested.
|
|
12
|
+
#
|
|
13
|
+
# Input (cursor on the `if` line):
|
|
14
|
+
# def charge_purchase(order)
|
|
15
|
+
# if order.fulfilled?
|
|
16
|
+
# OrderChargeConfirmation.new(order).create!
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Output:
|
|
21
|
+
# def charge_purchase(order)
|
|
22
|
+
# return unless order.fulfilled?
|
|
23
|
+
# OrderChargeConfirmation.new(order).create!
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Eligibility:
|
|
27
|
+
# - Block-form `if` (has end_keyword_loc) with no else/elsif.
|
|
28
|
+
# - Must be the first statement in the enclosing method body.
|
|
29
|
+
# - Body may contain one or more statements.
|
|
30
|
+
class EarlyReturnListener
|
|
31
|
+
include RubyLsp::Requests::Support::Common
|
|
32
|
+
include Support::NodeHelpers
|
|
33
|
+
|
|
34
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
35
|
+
@response_builder = response_builder
|
|
36
|
+
@node_context = node_context
|
|
37
|
+
@current_def = nil
|
|
38
|
+
|
|
39
|
+
dispatcher.register(
|
|
40
|
+
self,
|
|
41
|
+
:on_def_node_enter,
|
|
42
|
+
:on_def_node_leave,
|
|
43
|
+
:on_if_node_enter
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def on_def_node_enter(node)
|
|
48
|
+
@current_def = node
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def on_def_node_leave(_node)
|
|
54
|
+
@current_def = nil
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def on_if_node_enter(node)
|
|
60
|
+
return unless node_covers_cursor?(node)
|
|
61
|
+
return unless eligible?(node)
|
|
62
|
+
|
|
63
|
+
emit_early_return(node)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def eligible?(node)
|
|
71
|
+
return false unless node.end_keyword_loc # block-form only
|
|
72
|
+
return false if node.subsequent # no else/elsif
|
|
73
|
+
return false unless node.statements&.body&.any?
|
|
74
|
+
return false unless first_statement_in_def?(node)
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def first_statement_in_def?(if_node)
|
|
80
|
+
return false unless @current_def
|
|
81
|
+
|
|
82
|
+
body = @current_def.body
|
|
83
|
+
return false unless body.is_a?(Prism::StatementsNode)
|
|
84
|
+
|
|
85
|
+
body.body.first.equal?(if_node)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def emit_early_return(node)
|
|
89
|
+
indent = indent_for(node)
|
|
90
|
+
cond_src = node.predicate.location.slice.strip
|
|
91
|
+
body_lines = node.statements.body
|
|
92
|
+
.map { |s| "#{indent}#{s.location.slice.strip}" }
|
|
93
|
+
.join("\n")
|
|
94
|
+
|
|
95
|
+
new_text = "#{indent}return unless #{cond_src}\n#{body_lines}"
|
|
96
|
+
|
|
97
|
+
@response_builder << Interface::CodeAction.new(
|
|
98
|
+
title: "Convert to early return",
|
|
99
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
100
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../support/node_helpers"
|
|
5
|
+
|
|
6
|
+
module RubyLsp
|
|
7
|
+
module Refactor
|
|
8
|
+
# Offers "Extract to include file" when the cursor is on a ModuleNode or
|
|
9
|
+
# ClassNode that coexists with other top-level statements in the same file.
|
|
10
|
+
#
|
|
11
|
+
# The action:
|
|
12
|
+
# 1. Creates a new file named after the module/class (snake_case) in the
|
|
13
|
+
# same directory as the source file.
|
|
14
|
+
# 2. Writes the extracted node's source into the new file, prefixed with
|
|
15
|
+
# the standard `# frozen_string_literal: true` magic comment.
|
|
16
|
+
# 3. Replaces the extracted node in the source file with a
|
|
17
|
+
# `require_relative` statement.
|
|
18
|
+
#
|
|
19
|
+
# This uses `document_changes` (not `changes`) in the WorkspaceEdit so
|
|
20
|
+
# that the LSP client can handle the CreateFile resource operation.
|
|
21
|
+
#
|
|
22
|
+
# Input (cursor on the module, file also contains User class):
|
|
23
|
+
# # app/models/user.rb
|
|
24
|
+
# module Greetable
|
|
25
|
+
# def greet = "hello"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# class User
|
|
29
|
+
# include Greetable
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Output:
|
|
33
|
+
# # app/models/greetable.rb (new file)
|
|
34
|
+
# # frozen_string_literal: true
|
|
35
|
+
#
|
|
36
|
+
# module Greetable
|
|
37
|
+
# def greet = "hello"
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# # app/models/user.rb (modified)
|
|
41
|
+
# require_relative "greetable"
|
|
42
|
+
#
|
|
43
|
+
# class User
|
|
44
|
+
# include Greetable
|
|
45
|
+
# end
|
|
46
|
+
class ExtractIncludeFileListener
|
|
47
|
+
include RubyLsp::Requests::Support::Common
|
|
48
|
+
include Support::NodeHelpers
|
|
49
|
+
|
|
50
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
51
|
+
@response_builder = response_builder
|
|
52
|
+
@node_context = node_context
|
|
53
|
+
@top_level_count = 0
|
|
54
|
+
|
|
55
|
+
dispatcher.register(
|
|
56
|
+
self,
|
|
57
|
+
:on_program_node_enter,
|
|
58
|
+
:on_module_node_enter,
|
|
59
|
+
:on_class_node_enter
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Count top-level statements so we know whether extraction is meaningful.
|
|
64
|
+
def on_program_node_enter(node)
|
|
65
|
+
@top_level_count = node.statements.body.length
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def on_module_node_enter(node)
|
|
71
|
+
return unless node_covers_cursor?(node)
|
|
72
|
+
return unless top_level_node?(node)
|
|
73
|
+
return unless @top_level_count > 1
|
|
74
|
+
|
|
75
|
+
emit_extract(node, module_name(node))
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def on_class_node_enter(node)
|
|
81
|
+
return unless node_covers_cursor?(node)
|
|
82
|
+
return unless top_level_node?(node)
|
|
83
|
+
return unless @top_level_count > 1
|
|
84
|
+
|
|
85
|
+
emit_extract(node, class_name(node))
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# A node is top-level when its start column is 0 (not nested inside
|
|
93
|
+
# another class/module).
|
|
94
|
+
def top_level_node?(node)
|
|
95
|
+
node.location.start_column.zero?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def module_name(node)
|
|
99
|
+
node.constant_path.location.slice
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def class_name(node)
|
|
103
|
+
node.constant_path.location.slice
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def emit_extract(node, const_name)
|
|
107
|
+
source_uri_str = @node_context.uri
|
|
108
|
+
new_uri_str = new_file_uri(source_uri_str, const_name)
|
|
109
|
+
require_name = snake_case(const_name)
|
|
110
|
+
node_src = node.location.slice
|
|
111
|
+
|
|
112
|
+
# ── 1. Create the new file ──────────────────────────────────────────
|
|
113
|
+
create_op = create_file_operation(new_uri_str)
|
|
114
|
+
|
|
115
|
+
# ── 2. Write the extracted source into the new file ─────────────────
|
|
116
|
+
new_file_content = "# frozen_string_literal: true\n\n#{node_src}\n"
|
|
117
|
+
write_new_file = text_document_edit(
|
|
118
|
+
new_uri_str,
|
|
119
|
+
[
|
|
120
|
+
Interface::TextEdit.new(
|
|
121
|
+
range: Interface::Range.new(
|
|
122
|
+
start: Interface::Position.new(line: 0, character: 0),
|
|
123
|
+
end: Interface::Position.new(line: 0, character: 0)
|
|
124
|
+
),
|
|
125
|
+
new_text: new_file_content
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# ── 3. Replace the node in the source file with require_relative ────
|
|
131
|
+
# Include a trailing newline so the surrounding code stays clean.
|
|
132
|
+
require_text = "require_relative \"#{require_name}\"\n"
|
|
133
|
+
replace_in_src = text_document_edit(
|
|
134
|
+
source_uri_str,
|
|
135
|
+
[
|
|
136
|
+
Interface::TextEdit.new(
|
|
137
|
+
range: node_to_lsp_range(node),
|
|
138
|
+
new_text: require_text
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@response_builder << Interface::CodeAction.new(
|
|
144
|
+
title: "Extract to include file \"#{require_name}.rb\"",
|
|
145
|
+
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
146
|
+
edit: multi_file_workspace_edit([create_op, write_new_file, replace_in_src])
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Derives the URI for the new file from the source file's URI and the
|
|
151
|
+
# constant name. The new file is placed in the same directory.
|
|
152
|
+
def new_file_uri(source_uri_str, const_name)
|
|
153
|
+
filename = "#{snake_case(const_name)}.rb"
|
|
154
|
+
source_uri = URI(source_uri_str)
|
|
155
|
+
source_dir = File.dirname(source_uri.path)
|
|
156
|
+
new_path = File.join(source_dir, filename)
|
|
157
|
+
|
|
158
|
+
new_uri = source_uri.dup
|
|
159
|
+
new_uri.path = new_path
|
|
160
|
+
new_uri.to_s
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Converts CamelCase to snake_case.
|
|
164
|
+
def snake_case(name)
|
|
165
|
+
name
|
|
166
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
167
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
168
|
+
.downcase
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Offers "Extract predicate methods" when the cursor is on a compound
|
|
8
|
+
# `&&` or `||` expression that is the sole statement in a method body.
|
|
9
|
+
#
|
|
10
|
+
# From "Refactor Compound Conditionals into Methods" in Refactoring Rails:
|
|
11
|
+
# each operand becomes a private predicate method, making the condition
|
|
12
|
+
# self-documenting and each predicate independently testable.
|
|
13
|
+
#
|
|
14
|
+
# Input (cursor on the compound expression):
|
|
15
|
+
# def eligible_for_return?
|
|
16
|
+
# expired_orders.exclude?(self) && self.value > MINIMUM_RETURN_VALUE
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Output:
|
|
20
|
+
# def eligible_for_return?
|
|
21
|
+
# predicate_1? && predicate_2?
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# private
|
|
25
|
+
#
|
|
26
|
+
# def predicate_1?
|
|
27
|
+
# expired_orders.exclude?(self)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def predicate_2?
|
|
31
|
+
# self.value > MINIMUM_RETURN_VALUE
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# The generated names `predicate_1?` / `predicate_2?` are intentional
|
|
35
|
+
# placeholders — the developer renames them to reflect intent.
|
|
36
|
+
class ExtractPredicateListener
|
|
37
|
+
include RubyLsp::Requests::Support::Common
|
|
38
|
+
include Support::NodeHelpers
|
|
39
|
+
|
|
40
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
41
|
+
@response_builder = response_builder
|
|
42
|
+
@node_context = node_context
|
|
43
|
+
@current_def = nil
|
|
44
|
+
|
|
45
|
+
dispatcher.register(
|
|
46
|
+
self,
|
|
47
|
+
:on_def_node_enter,
|
|
48
|
+
:on_def_node_leave,
|
|
49
|
+
:on_and_node_enter,
|
|
50
|
+
:on_or_node_enter
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def on_def_node_enter(node)
|
|
55
|
+
@current_def = node
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def on_def_node_leave(_node)
|
|
61
|
+
@current_def = nil
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def on_and_node_enter(node)
|
|
67
|
+
return unless node_covers_cursor?(node)
|
|
68
|
+
return unless sole_statement_in_def?(node)
|
|
69
|
+
|
|
70
|
+
emit_extract_predicates(node, "&&")
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def on_or_node_enter(node)
|
|
76
|
+
return unless node_covers_cursor?(node)
|
|
77
|
+
return unless sole_statement_in_def?(node)
|
|
78
|
+
|
|
79
|
+
emit_extract_predicates(node, "||")
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The compound expression must be the only statement in the method body
|
|
87
|
+
# so the replacement is unambiguous.
|
|
88
|
+
def sole_statement_in_def?(node)
|
|
89
|
+
return false unless @current_def
|
|
90
|
+
|
|
91
|
+
body = @current_def.body
|
|
92
|
+
return false unless body.is_a?(Prism::StatementsNode)
|
|
93
|
+
return false unless body.body.length == 1
|
|
94
|
+
|
|
95
|
+
body.body.first.equal?(node)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def emit_extract_predicates(node, operator)
|
|
99
|
+
def_node = @current_def
|
|
100
|
+
indent = indent_for(def_node)
|
|
101
|
+
body_indent = "#{indent} "
|
|
102
|
+
|
|
103
|
+
left_src = node.left.location.slice.strip
|
|
104
|
+
right_src = node.right.location.slice.strip
|
|
105
|
+
|
|
106
|
+
# Replace the compound expression with calls to the two new predicates.
|
|
107
|
+
replace_edit = Interface::TextEdit.new(
|
|
108
|
+
range: node_to_lsp_range(node),
|
|
109
|
+
new_text: "#{body_indent}predicate_1? #{operator} predicate_2?"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Insert the two private predicate methods after the enclosing def.
|
|
113
|
+
insert_line = def_node.location.end_line
|
|
114
|
+
new_methods = "\n#{indent}private\n\n" \
|
|
115
|
+
"#{indent}def predicate_1?\n" \
|
|
116
|
+
"#{body_indent}#{left_src}\n" \
|
|
117
|
+
"#{indent}end\n\n" \
|
|
118
|
+
"#{indent}def predicate_2?\n" \
|
|
119
|
+
"#{body_indent}#{right_src}\n" \
|
|
120
|
+
"#{indent}end\n"
|
|
121
|
+
|
|
122
|
+
insert_edit = Interface::TextEdit.new(
|
|
123
|
+
range: Interface::Range.new(
|
|
124
|
+
start: Interface::Position.new(line: insert_line, character: 0),
|
|
125
|
+
end: Interface::Position.new(line: insert_line, character: 0)
|
|
126
|
+
),
|
|
127
|
+
new_text: new_methods
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@response_builder << Interface::CodeAction.new(
|
|
131
|
+
title: "Extract predicate methods",
|
|
132
|
+
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
133
|
+
edit: multi_edit_workspace_edit([replace_edit, insert_edit])
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -8,27 +8,22 @@ module RubyLsp
|
|
|
8
8
|
#
|
|
9
9
|
# Emitted actions
|
|
10
10
|
# ───────────────
|
|
11
|
-
# 1.
|
|
12
|
-
# Cursor on a LocalVariableWriteNode inside a def body.
|
|
13
|
-
# Extracts the assignment RHS (and the assignment itself) into a new
|
|
14
|
-
# private method, passing any variables that are defined before the
|
|
15
|
-
# extraction point and referenced inside the extracted expression as
|
|
16
|
-
# parameters.
|
|
17
|
-
#
|
|
18
|
-
# 2. Add parameter
|
|
11
|
+
# 1. Add parameter
|
|
19
12
|
# Cursor anywhere inside a DefNode.
|
|
20
13
|
# Appends a `new_param` placeholder at the end of the parameter list
|
|
21
14
|
# (or creates parentheses if the method has none).
|
|
22
15
|
#
|
|
23
|
-
#
|
|
16
|
+
# 2. Convert to keyword arguments
|
|
24
17
|
# Cursor anywhere inside a DefNode that has required positional params.
|
|
25
|
-
# Rewrites `def foo(a, b)` → `def foo(a:, b:)
|
|
26
|
-
# call-site within the same file that passes positional arguments.
|
|
18
|
+
# Rewrites `def foo(a, b)` → `def foo(a:, b:)`.
|
|
27
19
|
#
|
|
28
|
-
#
|
|
20
|
+
# 3. Extract to let (RSpec)
|
|
29
21
|
# Cursor on a LocalVariableWriteNode inside an RSpec `it`/`specify`
|
|
30
22
|
# block. Moves the assignment into a `let(:name) { value }` block
|
|
31
23
|
# inserted above the enclosing example group call.
|
|
24
|
+
#
|
|
25
|
+
# Note: "Extract to method" is provided by ruby-lsp upstream as
|
|
26
|
+
# "Refactor: Extract Method" and is intentionally not duplicated here.
|
|
32
27
|
class MethodListener
|
|
33
28
|
include RubyLsp::Requests::Support::Common
|
|
34
29
|
include Support::NodeHelpers
|
|
@@ -44,9 +39,6 @@ module RubyLsp
|
|
|
44
39
|
# Each entry is a Hash with :type and the node itself.
|
|
45
40
|
@ancestor_stack = []
|
|
46
41
|
|
|
47
|
-
# All write nodes seen so far (for "defined before extraction point").
|
|
48
|
-
@seen_writes = []
|
|
49
|
-
|
|
50
42
|
dispatcher.register(
|
|
51
43
|
self,
|
|
52
44
|
:on_def_node_enter,
|
|
@@ -74,7 +66,6 @@ module RubyLsp
|
|
|
74
66
|
|
|
75
67
|
def on_def_node_leave(_node)
|
|
76
68
|
@ancestor_stack.pop
|
|
77
|
-
@seen_writes.clear
|
|
78
69
|
rescue StandardError
|
|
79
70
|
nil
|
|
80
71
|
end
|
|
@@ -92,19 +83,11 @@ module RubyLsp
|
|
|
92
83
|
end
|
|
93
84
|
|
|
94
85
|
def on_local_variable_write_node_enter(node)
|
|
95
|
-
enclosing_def = nearest_ancestor(:def)
|
|
96
86
|
enclosing_call = nearest_ancestor(:call)
|
|
97
87
|
|
|
98
|
-
# Always track writes for param-detection, regardless of cursor position.
|
|
99
|
-
@seen_writes << node
|
|
100
|
-
|
|
101
88
|
return unless node_covers_cursor?(node)
|
|
102
89
|
|
|
103
|
-
if enclosing_call && rspec_example?(enclosing_call[:node])
|
|
104
|
-
emit_extract_to_let(node, enclosing_call[:node])
|
|
105
|
-
elsif enclosing_def
|
|
106
|
-
emit_extract_to_method(node, enclosing_def[:node])
|
|
107
|
-
end
|
|
90
|
+
emit_extract_to_let(node, enclosing_call[:node]) if enclosing_call && rspec_example?(enclosing_call[:node])
|
|
108
91
|
rescue StandardError
|
|
109
92
|
nil
|
|
110
93
|
end
|
|
@@ -117,62 +100,7 @@ module RubyLsp
|
|
|
117
100
|
@ancestor_stack.reverse.find { |a| a[:type] == type }
|
|
118
101
|
end
|
|
119
102
|
|
|
120
|
-
# ── 1.
|
|
121
|
-
|
|
122
|
-
def emit_extract_to_method(write_node, def_node)
|
|
123
|
-
method_name = write_node.name.to_s
|
|
124
|
-
rhs_src = write_node.value.location.slice.strip
|
|
125
|
-
indent = indent_for(def_node)
|
|
126
|
-
body_indent = "#{indent} "
|
|
127
|
-
|
|
128
|
-
# Determine which variables defined before this write are referenced
|
|
129
|
-
# inside the RHS expression.
|
|
130
|
-
params = params_needed_for(write_node.value, def_node)
|
|
131
|
-
param_list = params.empty? ? "" : "(#{params.join(", ")})"
|
|
132
|
-
call_args = params.empty? ? "" : "(#{params.join(", ")})"
|
|
133
|
-
|
|
134
|
-
# Replace the assignment RHS with a call to the new method.
|
|
135
|
-
replace_edit = Interface::TextEdit.new(
|
|
136
|
-
range: node_to_lsp_range(write_node.value),
|
|
137
|
-
new_text: "#{method_name}#{call_args}"
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
# Insert the new private method after the enclosing def's closing `end`.
|
|
141
|
-
insert_line = def_node.location.end_line # 1-based; insert after this line
|
|
142
|
-
new_method = "\n#{body_indent}private\n\n" \
|
|
143
|
-
"#{body_indent}def #{method_name}#{param_list}\n" \
|
|
144
|
-
"#{body_indent} #{rhs_src}\n" \
|
|
145
|
-
"#{body_indent}end\n"
|
|
146
|
-
|
|
147
|
-
insert_edit = Interface::TextEdit.new(
|
|
148
|
-
range: Interface::Range.new(
|
|
149
|
-
start: Interface::Position.new(line: insert_line, character: 0),
|
|
150
|
-
end: Interface::Position.new(line: insert_line, character: 0)
|
|
151
|
-
),
|
|
152
|
-
new_text: new_method
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
@response_builder << Interface::CodeAction.new(
|
|
156
|
-
title: "Extract to method '#{method_name}'",
|
|
157
|
-
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
158
|
-
edit: multi_edit_workspace_edit([replace_edit, insert_edit])
|
|
159
|
-
)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Collect names of variables that are:
|
|
163
|
-
# a) written before the extraction point in the same def body, AND
|
|
164
|
-
# b) referenced (read) inside +expr_node+.
|
|
165
|
-
def params_needed_for(expr_node, _def_node)
|
|
166
|
-
expr_src = expr_node.location.slice
|
|
167
|
-
|
|
168
|
-
@seen_writes
|
|
169
|
-
.select { |w| w.location.start_offset < expr_node.location.start_offset }
|
|
170
|
-
.map { |w| w.name.to_s }
|
|
171
|
-
.select { |name| expr_src.match?(/\b#{Regexp.escape(name)}\b/) }
|
|
172
|
-
.uniq
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# ── 2. Add parameter ─────────────────────────────────────────────────────
|
|
103
|
+
# ── 1. Add parameter ─────────────────────────────────────────────────────
|
|
176
104
|
|
|
177
105
|
def emit_add_parameter(def_node)
|
|
178
106
|
if def_node.parameters
|
|
@@ -220,7 +148,7 @@ module RubyLsp
|
|
|
220
148
|
].flatten.compact.last
|
|
221
149
|
end
|
|
222
150
|
|
|
223
|
-
# ──
|
|
151
|
+
# ── 2. Convert to keyword arguments ──────────────────────────────────────
|
|
224
152
|
|
|
225
153
|
def has_positional_params?(def_node)
|
|
226
154
|
def_node.parameters&.requireds&.any? { |p| p.is_a?(Prism::RequiredParameterNode) }
|
|
@@ -277,7 +205,7 @@ module RubyLsp
|
|
|
277
205
|
parts
|
|
278
206
|
end
|
|
279
207
|
|
|
280
|
-
# ──
|
|
208
|
+
# ── 3. Extract to let (RSpec) ─────────────────────────────────────────────
|
|
281
209
|
|
|
282
210
|
RSPEC_EXAMPLE_METHODS = %i[it specify example scenario].freeze
|
|
283
211
|
|