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
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ruby_lsp/addon"
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
|
|
5
|
+
# Conditionals
|
|
6
6
|
require_relative "listeners/conditional_listener"
|
|
7
|
-
require_relative "listeners/
|
|
7
|
+
require_relative "listeners/early_return_listener"
|
|
8
|
+
|
|
9
|
+
# Strings
|
|
8
10
|
require_relative "listeners/string_listener"
|
|
9
|
-
require_relative "listeners/
|
|
11
|
+
require_relative "listeners/string_array_listener"
|
|
12
|
+
require_relative "listeners/string_freeze_listener"
|
|
13
|
+
|
|
14
|
+
# Collections
|
|
10
15
|
require_relative "listeners/array_listener"
|
|
16
|
+
require_relative "listeners/hash_listener"
|
|
17
|
+
require_relative "listeners/enumerable_listener"
|
|
18
|
+
|
|
19
|
+
# Variables & constants
|
|
20
|
+
require_relative "listeners/variable_listener"
|
|
21
|
+
require_relative "listeners/constant_listener"
|
|
22
|
+
|
|
23
|
+
# Methods & classes
|
|
11
24
|
require_relative "listeners/method_listener"
|
|
25
|
+
require_relative "listeners/extract_predicate_listener"
|
|
26
|
+
require_relative "listeners/accessor_listener"
|
|
27
|
+
require_relative "listeners/rescue_listener"
|
|
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
|
|
39
|
+
require_relative "listeners/rspec_let_listener"
|
|
12
40
|
|
|
13
41
|
module RubyLsp
|
|
14
42
|
module Refactor
|
|
@@ -31,7 +59,7 @@ module RubyLsp
|
|
|
31
59
|
|
|
32
60
|
class Addon < ::RubyLsp::Addon
|
|
33
61
|
# Called once when the language server activates this add-on.
|
|
34
|
-
def activate(global_state,
|
|
62
|
+
def activate(global_state, _message_queue)
|
|
35
63
|
@global_state = global_state
|
|
36
64
|
|
|
37
65
|
# Inject our actions into the standard code-actions response.
|
|
@@ -62,30 +90,39 @@ module RubyLsp
|
|
|
62
90
|
|
|
63
91
|
cursor_range = Interface::Range.new(
|
|
64
92
|
start: Interface::Position.new(
|
|
65
|
-
line:
|
|
66
|
-
character: range.dig(:start, :character)
|
|
93
|
+
line: range.dig(:start, :line),
|
|
94
|
+
character: range.dig(:start, :character)
|
|
67
95
|
),
|
|
68
96
|
end: Interface::Position.new(
|
|
69
|
-
line:
|
|
70
|
-
character: range.dig(:end, :character)
|
|
71
|
-
)
|
|
97
|
+
line: range.dig(:end, :line),
|
|
98
|
+
character: range.dig(:end, :character)
|
|
99
|
+
)
|
|
72
100
|
)
|
|
73
101
|
|
|
74
102
|
node_context = NodeContext.new(document.uri.to_s, cursor_range)
|
|
75
103
|
response_builder = RubyLsp::ResponseBuilders::CollectionResponseBuilder.new
|
|
76
104
|
dispatcher = Prism::Dispatcher.new
|
|
77
105
|
|
|
78
|
-
# Phase 1 – Local rewrites
|
|
79
106
|
ConditionalListener.new(response_builder, node_context, dispatcher)
|
|
107
|
+
EarlyReturnListener.new(response_builder, node_context, dispatcher)
|
|
80
108
|
StringListener.new(response_builder, node_context, dispatcher)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
VariableListener.new(response_builder, node_context, dispatcher)
|
|
84
|
-
HashListener.new(response_builder, node_context, dispatcher)
|
|
109
|
+
StringArrayListener.new(response_builder, node_context, dispatcher)
|
|
110
|
+
StringFreezeListener.new(response_builder, node_context, dispatcher)
|
|
85
111
|
ArrayListener.new(response_builder, node_context, dispatcher)
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
HashListener.new(response_builder, node_context, dispatcher)
|
|
113
|
+
EnumerableListener.new(response_builder, node_context, dispatcher)
|
|
114
|
+
VariableListener.new(response_builder, node_context, dispatcher)
|
|
115
|
+
ConstantListener.new(response_builder, node_context, dispatcher)
|
|
88
116
|
MethodListener.new(response_builder, node_context, dispatcher)
|
|
117
|
+
ExtractPredicateListener.new(response_builder, node_context, dispatcher)
|
|
118
|
+
AccessorListener.new(response_builder, node_context, dispatcher)
|
|
119
|
+
RescueListener.new(response_builder, node_context, dispatcher)
|
|
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)
|
|
125
|
+
RspecLetListener.new(response_builder, node_context, dispatcher)
|
|
89
126
|
|
|
90
127
|
dispatcher.dispatch(document.ast)
|
|
91
128
|
response_builder.response
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Detects an `attr_reader :name` paired with a canonical manual writer
|
|
8
|
+
# `def name=(val); @name = val; end` in the same class body and offers
|
|
9
|
+
# to collapse them into a single `attr_accessor :name`.
|
|
10
|
+
#
|
|
11
|
+
# Input (cursor on either the attr_reader or the writer def):
|
|
12
|
+
# attr_reader :name
|
|
13
|
+
# def name=(val)
|
|
14
|
+
# @name = val
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Output:
|
|
18
|
+
# attr_accessor :name
|
|
19
|
+
#
|
|
20
|
+
# The manual writer is considered canonical when its body contains exactly
|
|
21
|
+
# one statement of the form `@name = val` where `val` is the sole parameter.
|
|
22
|
+
class AccessorListener
|
|
23
|
+
include RubyLsp::Requests::Support::Common
|
|
24
|
+
include Support::NodeHelpers
|
|
25
|
+
|
|
26
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
27
|
+
@response_builder = response_builder
|
|
28
|
+
@node_context = node_context
|
|
29
|
+
|
|
30
|
+
# Collect attr_reader calls and writer defs within the same class body.
|
|
31
|
+
@attr_readers = [] # [{ name:, node: }]
|
|
32
|
+
@writer_defs = [] # [{ name:, node: }]
|
|
33
|
+
@class_depth = 0
|
|
34
|
+
|
|
35
|
+
dispatcher.register(
|
|
36
|
+
self,
|
|
37
|
+
:on_class_node_enter,
|
|
38
|
+
:on_class_node_leave,
|
|
39
|
+
:on_call_node_enter,
|
|
40
|
+
:on_def_node_enter
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def on_class_node_enter(_node)
|
|
45
|
+
@class_depth += 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_class_node_leave(_node)
|
|
49
|
+
@class_depth -= 1
|
|
50
|
+
# Emit any matched pairs before clearing — on_program_node_leave fires
|
|
51
|
+
# after on_class_node_leave, so we must act here while data is present.
|
|
52
|
+
emit_matching_pairs
|
|
53
|
+
@attr_readers.clear
|
|
54
|
+
@writer_defs.clear
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_call_node_enter(node)
|
|
58
|
+
return unless @class_depth.positive?
|
|
59
|
+
return unless node.name == :attr_reader
|
|
60
|
+
return unless node.arguments
|
|
61
|
+
|
|
62
|
+
node.arguments.arguments.each do |arg|
|
|
63
|
+
next unless arg.is_a?(Prism::SymbolNode)
|
|
64
|
+
|
|
65
|
+
@attr_readers << { name: arg.unescaped.to_sym, node: node, sym_node: arg }
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_def_node_enter(node)
|
|
72
|
+
return unless @class_depth.positive?
|
|
73
|
+
return unless node.name.to_s.end_with?("=")
|
|
74
|
+
|
|
75
|
+
attr_name = node.name.to_s.delete_suffix("=").to_sym
|
|
76
|
+
return unless canonical_writer?(node, attr_name)
|
|
77
|
+
|
|
78
|
+
@writer_defs << { name: attr_name, node: node }
|
|
79
|
+
rescue StandardError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Match readers with writers and emit actions for any pair where the
|
|
86
|
+
# cursor falls on either node.
|
|
87
|
+
def emit_matching_pairs
|
|
88
|
+
@attr_readers.each do |reader|
|
|
89
|
+
writer = @writer_defs.find { |w| w[:name] == reader[:name] }
|
|
90
|
+
next unless writer
|
|
91
|
+
|
|
92
|
+
next unless node_covers_cursor?(reader[:node]) ||
|
|
93
|
+
node_covers_cursor?(writer[:node])
|
|
94
|
+
|
|
95
|
+
emit_collapse(reader, writer)
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A writer def is canonical when:
|
|
102
|
+
# 1. It has exactly one required parameter.
|
|
103
|
+
# 2. Its body is exactly one statement: `@name = <param>`.
|
|
104
|
+
def canonical_writer?(def_node, attr_name)
|
|
105
|
+
params = def_node.parameters&.requireds
|
|
106
|
+
return false unless params&.length == 1
|
|
107
|
+
|
|
108
|
+
param_name = params.first.name
|
|
109
|
+
body_stmts = def_node.body&.body
|
|
110
|
+
return false unless body_stmts&.length == 1
|
|
111
|
+
|
|
112
|
+
stmt = body_stmts.first
|
|
113
|
+
return false unless stmt.is_a?(Prism::InstanceVariableWriteNode)
|
|
114
|
+
return false unless stmt.name.to_s == "@#{attr_name}"
|
|
115
|
+
return false unless stmt.value.is_a?(Prism::LocalVariableReadNode)
|
|
116
|
+
return false unless stmt.value.name == param_name
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def emit_collapse(reader, writer)
|
|
122
|
+
reader_node = reader[:node]
|
|
123
|
+
writer_node = writer[:node]
|
|
124
|
+
attr_name = reader[:name]
|
|
125
|
+
|
|
126
|
+
# Replace the attr_reader line with attr_accessor.
|
|
127
|
+
# The sym_node is the :name argument; we keep the same symbol.
|
|
128
|
+
reader_src = reader_node.location.slice
|
|
129
|
+
new_reader = reader_src.sub("attr_reader", "attr_accessor")
|
|
130
|
+
|
|
131
|
+
replace_reader = Interface::TextEdit.new(
|
|
132
|
+
range: node_to_lsp_range(reader_node),
|
|
133
|
+
new_text: new_reader
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Delete the entire writer def (all its lines).
|
|
137
|
+
writer_start_line = writer_node.location.start_line - 1
|
|
138
|
+
writer_end_line = writer_node.location.end_line
|
|
139
|
+
|
|
140
|
+
delete_writer = Interface::TextEdit.new(
|
|
141
|
+
range: Interface::Range.new(
|
|
142
|
+
start: Interface::Position.new(line: writer_start_line, character: 0),
|
|
143
|
+
end: Interface::Position.new(line: writer_end_line, character: 0)
|
|
144
|
+
),
|
|
145
|
+
new_text: ""
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@response_builder << Interface::CodeAction.new(
|
|
149
|
+
title: "Convert to attr_accessor :#{attr_name}",
|
|
150
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
151
|
+
edit: multi_edit_workspace_edit([replace_reader, delete_writer])
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -56,8 +56,8 @@ module RubyLsp
|
|
|
56
56
|
|
|
57
57
|
@response_builder << Interface::CodeAction.new(
|
|
58
58
|
title: "Convert to symbol array",
|
|
59
|
-
kind:
|
|
60
|
-
edit:
|
|
59
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
60
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
61
61
|
)
|
|
62
62
|
end
|
|
63
63
|
end
|
|
@@ -127,8 +127,8 @@ module RubyLsp
|
|
|
127
127
|
|
|
128
128
|
@response_builder << Interface::CodeAction.new(
|
|
129
129
|
title: "Convert to post-conditional",
|
|
130
|
-
kind:
|
|
131
|
-
edit:
|
|
130
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
131
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
132
132
|
)
|
|
133
133
|
end
|
|
134
134
|
|
|
@@ -141,8 +141,8 @@ module RubyLsp
|
|
|
141
141
|
|
|
142
142
|
@response_builder << Interface::CodeAction.new(
|
|
143
143
|
title: "Convert to post-conditional",
|
|
144
|
-
kind:
|
|
145
|
-
edit:
|
|
144
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
145
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
146
146
|
)
|
|
147
147
|
end
|
|
148
148
|
|
|
@@ -155,8 +155,8 @@ module RubyLsp
|
|
|
155
155
|
|
|
156
156
|
@response_builder << Interface::CodeAction.new(
|
|
157
157
|
title: "Convert to block if",
|
|
158
|
-
kind:
|
|
159
|
-
edit:
|
|
158
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
159
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
160
160
|
)
|
|
161
161
|
end
|
|
162
162
|
|
|
@@ -169,8 +169,8 @@ module RubyLsp
|
|
|
169
169
|
|
|
170
170
|
@response_builder << Interface::CodeAction.new(
|
|
171
171
|
title: "Convert to block unless",
|
|
172
|
-
kind:
|
|
173
|
-
edit:
|
|
172
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
173
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
174
174
|
)
|
|
175
175
|
end
|
|
176
176
|
|
|
@@ -183,8 +183,8 @@ module RubyLsp
|
|
|
183
183
|
|
|
184
184
|
@response_builder << Interface::CodeAction.new(
|
|
185
185
|
title: "Convert to unless",
|
|
186
|
-
kind:
|
|
187
|
-
edit:
|
|
186
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
187
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
188
188
|
)
|
|
189
189
|
end
|
|
190
190
|
|
|
@@ -197,8 +197,8 @@ module RubyLsp
|
|
|
197
197
|
|
|
198
198
|
@response_builder << Interface::CodeAction.new(
|
|
199
199
|
title: "Convert to if",
|
|
200
|
-
kind:
|
|
201
|
-
edit:
|
|
200
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
201
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
202
202
|
)
|
|
203
203
|
end
|
|
204
204
|
|
|
@@ -212,8 +212,8 @@ module RubyLsp
|
|
|
212
212
|
|
|
213
213
|
@response_builder << Interface::CodeAction.new(
|
|
214
214
|
title: "Invert if/else",
|
|
215
|
-
kind:
|
|
216
|
-
edit:
|
|
215
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
216
|
+
edit: single_edit_workspace_edit(node, new_text)
|
|
217
217
|
)
|
|
218
218
|
end
|
|
219
219
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Offers "Extract constant" when the cursor is on a literal value
|
|
8
|
+
# (Integer, Float, String, Symbol) inside a class or module body.
|
|
9
|
+
#
|
|
10
|
+
# The new constant is inserted at the top of the enclosing class/module
|
|
11
|
+
# body, and the literal is replaced with the constant name.
|
|
12
|
+
#
|
|
13
|
+
# Input (cursor on `100`):
|
|
14
|
+
# class Processor
|
|
15
|
+
# def run
|
|
16
|
+
# items.first(100)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Output:
|
|
21
|
+
# class Processor
|
|
22
|
+
# EXTRACTED_CONSTANT = 100
|
|
23
|
+
#
|
|
24
|
+
# def run
|
|
25
|
+
# items.first(EXTRACTED_CONSTANT)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
class ConstantListener
|
|
29
|
+
include RubyLsp::Requests::Support::Common
|
|
30
|
+
include Support::NodeHelpers
|
|
31
|
+
|
|
32
|
+
LITERAL_NODE_TYPES = [
|
|
33
|
+
Prism::IntegerNode,
|
|
34
|
+
Prism::FloatNode,
|
|
35
|
+
Prism::StringNode,
|
|
36
|
+
Prism::SymbolNode
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
40
|
+
@response_builder = response_builder
|
|
41
|
+
@node_context = node_context
|
|
42
|
+
|
|
43
|
+
# Track the nearest enclosing class/module so we know where to insert.
|
|
44
|
+
@class_stack = []
|
|
45
|
+
|
|
46
|
+
dispatcher.register(
|
|
47
|
+
self,
|
|
48
|
+
:on_class_node_enter,
|
|
49
|
+
:on_class_node_leave,
|
|
50
|
+
:on_module_node_enter,
|
|
51
|
+
:on_module_node_leave,
|
|
52
|
+
:on_integer_node_enter,
|
|
53
|
+
:on_float_node_enter,
|
|
54
|
+
:on_string_node_enter,
|
|
55
|
+
:on_symbol_node_enter
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def on_class_node_enter(node) = @class_stack.push(node)
|
|
60
|
+
def on_class_node_leave(_node) = @class_stack.pop
|
|
61
|
+
def on_module_node_enter(node) = @class_stack.push(node)
|
|
62
|
+
def on_module_node_leave(_node) = @class_stack.pop
|
|
63
|
+
|
|
64
|
+
def on_integer_node_enter(node) = maybe_emit(node)
|
|
65
|
+
def on_float_node_enter(node) = maybe_emit(node)
|
|
66
|
+
def on_string_node_enter(node) = maybe_emit(node)
|
|
67
|
+
def on_symbol_node_enter(node) = maybe_emit(node)
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def maybe_emit(node)
|
|
72
|
+
return unless node_covers_cursor?(node)
|
|
73
|
+
return if @class_stack.empty?
|
|
74
|
+
|
|
75
|
+
# Don't offer on constant assignments themselves.
|
|
76
|
+
enclosing = @class_stack.last
|
|
77
|
+
return unless enclosing
|
|
78
|
+
|
|
79
|
+
emit_extract_constant(node, enclosing)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def emit_extract_constant(literal_node, class_node)
|
|
85
|
+
literal_src = literal_node.location.slice.strip
|
|
86
|
+
indent = indent_for(class_node)
|
|
87
|
+
body_indent = "#{indent} "
|
|
88
|
+
|
|
89
|
+
# Insert the constant declaration at the top of the class body.
|
|
90
|
+
# The class body starts on the line after the class declaration.
|
|
91
|
+
body_start_line = class_node.body&.location&.start_line
|
|
92
|
+
return unless body_start_line
|
|
93
|
+
|
|
94
|
+
insert_line = body_start_line - 1
|
|
95
|
+
const_decl = "#{body_indent}EXTRACTED_CONSTANT = #{literal_src}\n\n"
|
|
96
|
+
|
|
97
|
+
insert_edit = Interface::TextEdit.new(
|
|
98
|
+
range: Interface::Range.new(
|
|
99
|
+
start: Interface::Position.new(line: insert_line, character: 0),
|
|
100
|
+
end: Interface::Position.new(line: insert_line, character: 0)
|
|
101
|
+
),
|
|
102
|
+
new_text: const_decl
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
replace_edit = Interface::TextEdit.new(
|
|
106
|
+
range: node_to_lsp_range(literal_node),
|
|
107
|
+
new_text: "EXTRACTED_CONSTANT"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@response_builder << Interface::CodeAction.new(
|
|
111
|
+
title: "Extract constant",
|
|
112
|
+
kind: Constant::CodeActionKind::REFACTOR_EXTRACT,
|
|
113
|
+
edit: multi_edit_workspace_edit([insert_edit, replace_edit])
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Detects common Enumerable method-chain patterns and offers a single
|
|
8
|
+
# collapsed replacement.
|
|
9
|
+
#
|
|
10
|
+
# Emitted actions
|
|
11
|
+
# ───────────────
|
|
12
|
+
# 1. map + flatten(1) / flatten → flat_map
|
|
13
|
+
# items.map { |i| i.tags }.flatten(1) → items.flat_map { |i| i.tags }
|
|
14
|
+
#
|
|
15
|
+
# 2. select + first → find
|
|
16
|
+
# users.select { |u| u.admin? }.first → users.find { |u| u.admin? }
|
|
17
|
+
#
|
|
18
|
+
# 3. map + compact → filter_map
|
|
19
|
+
# items.map { |i| i.value }.compact → items.filter_map { |i| i.value }
|
|
20
|
+
#
|
|
21
|
+
# All three patterns share the same structure:
|
|
22
|
+
# outer_call( receiver: inner_call( block: BlockNode ) )
|
|
23
|
+
# where outer_call has no block of its own.
|
|
24
|
+
class EnumerableListener
|
|
25
|
+
include RubyLsp::Requests::Support::Common
|
|
26
|
+
include Support::NodeHelpers
|
|
27
|
+
|
|
28
|
+
# { outer_method => { inner_method => replacement } }
|
|
29
|
+
PATTERNS = {
|
|
30
|
+
flatten: { map: "flat_map" },
|
|
31
|
+
first: { select: "find" },
|
|
32
|
+
compact: { map: "filter_map" }
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
36
|
+
@response_builder = response_builder
|
|
37
|
+
@node_context = node_context
|
|
38
|
+
|
|
39
|
+
dispatcher.register(self, :on_call_node_enter)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def on_call_node_enter(node)
|
|
43
|
+
return unless node_covers_cursor?(node)
|
|
44
|
+
return if node.block # outer call must not have its own block
|
|
45
|
+
|
|
46
|
+
outer_name = node.name
|
|
47
|
+
return unless PATTERNS.key?(outer_name)
|
|
48
|
+
|
|
49
|
+
# flatten is only eligible when called with no args or with arg `1`.
|
|
50
|
+
return if outer_name == :flatten && !flatten_eligible?(node)
|
|
51
|
+
|
|
52
|
+
inner = node.receiver
|
|
53
|
+
return unless inner.is_a?(Prism::CallNode) && inner.block.is_a?(Prism::BlockNode)
|
|
54
|
+
|
|
55
|
+
replacement = PATTERNS[outer_name][inner.name]
|
|
56
|
+
return unless replacement
|
|
57
|
+
|
|
58
|
+
emit_collapse(node, inner, replacement)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# flatten() and flatten(1) are eligible; flatten(2+) changes semantics.
|
|
66
|
+
def flatten_eligible?(node)
|
|
67
|
+
args = node.arguments&.arguments
|
|
68
|
+
return true if args.nil? || args.empty?
|
|
69
|
+
return true if args.length == 1 && args.first.is_a?(Prism::IntegerNode) &&
|
|
70
|
+
args.first.location.slice == "1"
|
|
71
|
+
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def emit_collapse(outer_node, inner_node, replacement)
|
|
76
|
+
# Reconstruct: <receiver>.<replacement> <block>
|
|
77
|
+
receiver_src = inner_node.receiver.location.slice.strip
|
|
78
|
+
block_src = inner_node.block.location.slice.strip
|
|
79
|
+
|
|
80
|
+
new_text = "#{indent_for(outer_node)}#{receiver_src}.#{replacement} #{block_src}"
|
|
81
|
+
|
|
82
|
+
@response_builder << Interface::CodeAction.new(
|
|
83
|
+
title: "Convert to .#{replacement}",
|
|
84
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
85
|
+
edit: single_edit_workspace_edit(outer_node, new_text)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|