ruby-lsp-refactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +327 -0
- data/Rakefile +16 -0
- data/lib/ruby/lsp/refactor/version.rb +9 -0
- data/lib/ruby/lsp/refactor.rb +12 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/addon.rb +97 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/array_listener.rb +65 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/conditional_listener.rb +250 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/hash_listener.rb +97 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/method_listener.rb +316 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/string_listener.rb +61 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/listeners/variable_listener.rb +134 -0
- data/lib/ruby_lsp/ruby_lsp_refactor/support/node_helpers.rb +101 -0
- data/lib/ruby_lsp/test_helper.rb +50 -0
- data/sig/ruby/lsp/refactor.rbs +8 -0
- data/test/ruby_lsp_refactor/array_listener_test.rb +82 -0
- data/test/ruby_lsp_refactor/conditional_listener_test.rb +307 -0
- data/test/ruby_lsp_refactor/hash_listener_test.rb +72 -0
- data/test/ruby_lsp_refactor/method_listener_test.rb +193 -0
- data/test/ruby_lsp_refactor/string_listener_test.rb +70 -0
- data/test/ruby_lsp_refactor/variable_listener_test.rb +97 -0
- metadata +143 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e93bae39d557e3ff626edb5cd4465bb6ad637011f806b36900063777879f9e5d
|
|
4
|
+
data.tar.gz: a9f97f89abb0fa58a65d3fce8e451f9ee74a78bf1249f8d22045b1dffd18953b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b1ae08611fbb7d89e77fd5bad6111889317d158a79f7e1bf47d3acc229e6ed3b58c6a8ff2059359d71d614ea95b742ab40f22db7e610e36b2d3b15e9345c24a0
|
|
7
|
+
data.tar.gz: 8afd108cdaa93c2a8c17fa9ab123da4a117254d71e7044f3b2ce1f21c2649d4a3d5fc57c690be34fd1ee629b2b2d961f1157bf814c90ec4c0f0b9ff82c121fc6
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aboobacker MK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# ruby-lsp-refactor
|
|
2
|
+
|
|
3
|
+
A [ruby-lsp](https://github.com/Shopify/ruby-lsp) add-on that provides safe,
|
|
4
|
+
AST-driven refactoring code actions natively inside any LSP-supported editor
|
|
5
|
+
(VS Code, Zed, Neovim, RubyMine, etc.).
|
|
6
|
+
|
|
7
|
+
All refactors are powered by the [Prism](https://github.com/ruby/prism) parser
|
|
8
|
+
and operate on the real AST — no regex substitutions.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Add the gem to your project's `Gemfile` (it only needs to be available to the
|
|
13
|
+
language server, so the `:development` group is the right place):
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
group :development do
|
|
17
|
+
gem "ruby-lsp-refactor"
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then run:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The add-on is discovered and activated automatically by ruby-lsp — no further
|
|
28
|
+
configuration is required.
|
|
29
|
+
|
|
30
|
+
## Supported refactorings
|
|
31
|
+
|
|
32
|
+
Place your cursor anywhere on the relevant construct and open the code-actions
|
|
33
|
+
menu (`Cmd+.` in VS Code / Zed, or your editor's equivalent).
|
|
34
|
+
|
|
35
|
+
### Phase 1 — Local rewrites
|
|
36
|
+
|
|
37
|
+
#### Convert to post-conditional
|
|
38
|
+
|
|
39
|
+
Collapses a single-statement `if` or `unless` block into a trailing modifier.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Before
|
|
43
|
+
if user.qualified?
|
|
44
|
+
user.approve!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# After
|
|
48
|
+
user.approve! if user.qualified?
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Works with `unless` too:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Before
|
|
55
|
+
unless user.banned?
|
|
56
|
+
user.login!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# After
|
|
60
|
+
user.login! unless user.banned?
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### Convert to block if / Convert to block unless
|
|
64
|
+
|
|
65
|
+
The reverse operation — expands a trailing modifier back into a full block.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Before
|
|
69
|
+
user.approve! if user.qualified?
|
|
70
|
+
|
|
71
|
+
# After
|
|
72
|
+
if user.qualified?
|
|
73
|
+
user.approve!
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### Convert to unless / Convert to if
|
|
78
|
+
|
|
79
|
+
Toggles between `if` and `unless` on a block conditional that has no `else`
|
|
80
|
+
branch. When the predicate already starts with `!`, the negation is stripped
|
|
81
|
+
automatically to keep the result clean.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# Before
|
|
85
|
+
if user.active?
|
|
86
|
+
user.greet!
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# After
|
|
90
|
+
unless user.active?
|
|
91
|
+
user.greet!
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# Before — negated predicate
|
|
97
|
+
if !user.banned?
|
|
98
|
+
user.login!
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# After — negation stripped
|
|
102
|
+
unless user.banned?
|
|
103
|
+
user.login!
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### Invert if/else
|
|
108
|
+
|
|
109
|
+
Negates the condition and swaps the two branches of an `if/else` block.
|
|
110
|
+
Double-negation (`!!`) is cancelled automatically.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# Before
|
|
114
|
+
if user.admin?
|
|
115
|
+
grant!
|
|
116
|
+
else
|
|
117
|
+
deny!
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# After
|
|
121
|
+
if !user.admin?
|
|
122
|
+
deny!
|
|
123
|
+
else
|
|
124
|
+
grant!
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### Convert to interpolated string
|
|
129
|
+
|
|
130
|
+
Upgrades a single-quoted string literal to double-quotes so you can immediately
|
|
131
|
+
add `#{}` interpolation. Any `"` characters inside the string are escaped.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Before
|
|
135
|
+
'hello world'
|
|
136
|
+
|
|
137
|
+
# After
|
|
138
|
+
"hello world"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### Phase 2 — Variable & literal optimisation
|
|
144
|
+
|
|
145
|
+
#### Inline variable
|
|
146
|
+
|
|
147
|
+
Removes a local variable assignment and replaces every subsequent read of that
|
|
148
|
+
variable with the original right-hand-side expression.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# Before — cursor on the assignment line
|
|
152
|
+
result = user.calculate
|
|
153
|
+
puts result
|
|
154
|
+
log result
|
|
155
|
+
|
|
156
|
+
# After
|
|
157
|
+
puts user.calculate
|
|
158
|
+
log user.calculate
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### Extract local variable
|
|
162
|
+
|
|
163
|
+
Wraps any expression under the cursor in a new local variable assignment
|
|
164
|
+
inserted on the line above.
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# Before — cursor on the expression
|
|
168
|
+
user.full_name.upcase
|
|
169
|
+
|
|
170
|
+
# After
|
|
171
|
+
variable = user.full_name.upcase
|
|
172
|
+
variable
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Convert to keyword syntax
|
|
176
|
+
|
|
177
|
+
Converts hash-rocket pairs whose keys are plain symbols into modern keyword
|
|
178
|
+
syntax. Mixed hashes (string keys, computed keys) are handled gracefully —
|
|
179
|
+
only the eligible pairs are converted.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# Before
|
|
183
|
+
{ :name => "Alice", :age => 30 }
|
|
184
|
+
|
|
185
|
+
# After
|
|
186
|
+
{ name: "Alice", age: 30 }
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### Convert to symbol array
|
|
190
|
+
|
|
191
|
+
Converts a bracket array of plain symbols into a `%i[]` word array.
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Before
|
|
195
|
+
[:foo, :bar, :baz]
|
|
196
|
+
|
|
197
|
+
# After
|
|
198
|
+
%i[foo bar baz]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### Phase 3 — Advanced structure
|
|
204
|
+
|
|
205
|
+
#### Extract to method
|
|
206
|
+
|
|
207
|
+
Extracts a local variable's right-hand-side expression into a new `private`
|
|
208
|
+
method. Variables that are defined before the extraction point and referenced
|
|
209
|
+
inside the expression are automatically detected and forwarded as method
|
|
210
|
+
parameters.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
# Before — cursor on the assignment
|
|
214
|
+
def process(data)
|
|
215
|
+
threshold = 10
|
|
216
|
+
result = data.select { |x| x > threshold }
|
|
217
|
+
result
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# After
|
|
221
|
+
def process(data)
|
|
222
|
+
threshold = 10
|
|
223
|
+
result = result(threshold)
|
|
224
|
+
result
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def result(threshold)
|
|
230
|
+
data.select { |x| x > threshold }
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
#### Add parameter
|
|
235
|
+
|
|
236
|
+
Appends a `new_param` placeholder to a method's parameter list. If the method
|
|
237
|
+
has no parameters yet, parentheses are added automatically.
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# Before — cursor anywhere inside the def
|
|
241
|
+
def greet(name)
|
|
242
|
+
puts name
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# After
|
|
246
|
+
def greet(name, new_param)
|
|
247
|
+
puts name
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
# Before — no parameters
|
|
253
|
+
def greet
|
|
254
|
+
puts "hello"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# After
|
|
258
|
+
def greet(new_param)
|
|
259
|
+
puts "hello"
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### Convert to keyword arguments
|
|
264
|
+
|
|
265
|
+
Rewrites all required positional parameters in a method signature to keyword
|
|
266
|
+
arguments. Optional parameters, rest args, and block parameters are left
|
|
267
|
+
unchanged.
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
# Before — cursor anywhere inside the def
|
|
271
|
+
def create(name, age)
|
|
272
|
+
User.new(name, age)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# After
|
|
276
|
+
def create(name:, age:)
|
|
277
|
+
User.new(name, age)
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
#### Extract to let _(RSpec)_
|
|
282
|
+
|
|
283
|
+
When the cursor is on a local variable assignment inside an RSpec `it`,
|
|
284
|
+
`specify`, `example`, or `scenario` block, this action moves the assignment
|
|
285
|
+
into a `let` declaration inserted above the example.
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# Before — cursor on the assignment
|
|
289
|
+
it "logs in" do
|
|
290
|
+
user = User.new(name: "Alice")
|
|
291
|
+
expect(user.name).to eq("Alice")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# After
|
|
295
|
+
let(:user) { User.new(name: "Alice") }
|
|
296
|
+
|
|
297
|
+
it "logs in" do
|
|
298
|
+
expect(user.name).to eq("Alice")
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Development
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
bin/setup # install dependencies
|
|
308
|
+
bundle exec rake test # run the test suite
|
|
309
|
+
bundle exec rake # lint + test
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
To try the add-on against a local project without publishing to RubyGems, add
|
|
313
|
+
a path reference to that project's `Gemfile`:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
gem "ruby-lsp-refactor", path: "/path/to/ruby-lsp-refactor"
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Contributing
|
|
320
|
+
|
|
321
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
322
|
+
https://github.com/tachyons/ruby-lsp-refactor.
|
|
323
|
+
|
|
324
|
+
## License
|
|
325
|
+
|
|
326
|
+
The gem is available as open source under the terms of the
|
|
327
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "rubocop/rake_task"
|
|
6
|
+
|
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
|
8
|
+
t.libs << "test"
|
|
9
|
+
t.libs << "lib"
|
|
10
|
+
t.pattern = "test/**/*_test.rb"
|
|
11
|
+
t.verbose = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RuboCop::RakeTask.new
|
|
15
|
+
|
|
16
|
+
task default: %i[test rubocop]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_lsp/addon"
|
|
4
|
+
require "ruby_lsp/requests/code_actions"
|
|
5
|
+
require "ruby_lsp/requests/code_action_resolve"
|
|
6
|
+
require_relative "listeners/conditional_listener"
|
|
7
|
+
require_relative "listeners/variable_listener"
|
|
8
|
+
require_relative "listeners/string_listener"
|
|
9
|
+
require_relative "listeners/hash_listener"
|
|
10
|
+
require_relative "listeners/array_listener"
|
|
11
|
+
require_relative "listeners/method_listener"
|
|
12
|
+
|
|
13
|
+
module RubyLsp
|
|
14
|
+
module Refactor
|
|
15
|
+
# Lightweight value object that satisfies the interface expected by every
|
|
16
|
+
# listener: #uri and #range.
|
|
17
|
+
NodeContext = Struct.new(:uri, :range)
|
|
18
|
+
|
|
19
|
+
# Prepended into RubyLsp::Requests::CodeActions#perform.
|
|
20
|
+
#
|
|
21
|
+
# Runs our own AST walk and appends the resulting actions to whatever
|
|
22
|
+
# ruby-lsp itself returns. Each action carries a full `edit:` so no
|
|
23
|
+
# resolve round-trip is needed (the LSP spec allows this).
|
|
24
|
+
module CodeActionsExtension
|
|
25
|
+
def perform
|
|
26
|
+
actions = super || []
|
|
27
|
+
actions.concat(RubyLsp::Refactor::Addon.refactor_actions_for(@document, @range))
|
|
28
|
+
actions
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Addon < ::RubyLsp::Addon
|
|
33
|
+
# Called once when the language server activates this add-on.
|
|
34
|
+
def activate(global_state, message_queue)
|
|
35
|
+
@global_state = global_state
|
|
36
|
+
|
|
37
|
+
# Inject our actions into the standard code-actions response.
|
|
38
|
+
RubyLsp::Requests::CodeActions.prepend(CodeActionsExtension)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deactivate; end
|
|
42
|
+
|
|
43
|
+
def name
|
|
44
|
+
"Ruby LSP Refactor"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def version
|
|
48
|
+
"0.1.0"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Runs the full listener pipeline against +document+ at +range+ and
|
|
52
|
+
# returns an array of Interface::CodeAction objects.
|
|
53
|
+
#
|
|
54
|
+
# Called from CodeActionsExtension#perform and from the test helper.
|
|
55
|
+
#
|
|
56
|
+
# @param document [RubyLsp::RubyDocument]
|
|
57
|
+
# @param range [Hash] LSP range hash { start: {line:, character:}, end: {line:, character:} }
|
|
58
|
+
# @return [Array<Interface::CodeAction>]
|
|
59
|
+
def self.refactor_actions_for(document, range)
|
|
60
|
+
return [] unless document.is_a?(RubyLsp::RubyDocument)
|
|
61
|
+
return [] if document.source.empty?
|
|
62
|
+
|
|
63
|
+
cursor_range = Interface::Range.new(
|
|
64
|
+
start: Interface::Position.new(
|
|
65
|
+
line: range.dig(:start, :line),
|
|
66
|
+
character: range.dig(:start, :character),
|
|
67
|
+
),
|
|
68
|
+
end: Interface::Position.new(
|
|
69
|
+
line: range.dig(:end, :line),
|
|
70
|
+
character: range.dig(:end, :character),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
node_context = NodeContext.new(document.uri.to_s, cursor_range)
|
|
75
|
+
response_builder = RubyLsp::ResponseBuilders::CollectionResponseBuilder.new
|
|
76
|
+
dispatcher = Prism::Dispatcher.new
|
|
77
|
+
|
|
78
|
+
# Phase 1 – Local rewrites
|
|
79
|
+
ConditionalListener.new(response_builder, node_context, dispatcher)
|
|
80
|
+
StringListener.new(response_builder, node_context, dispatcher)
|
|
81
|
+
|
|
82
|
+
# Phase 2 – Variable & literal optimisation
|
|
83
|
+
VariableListener.new(response_builder, node_context, dispatcher)
|
|
84
|
+
HashListener.new(response_builder, node_context, dispatcher)
|
|
85
|
+
ArrayListener.new(response_builder, node_context, dispatcher)
|
|
86
|
+
|
|
87
|
+
# Phase 3 – Advanced structure
|
|
88
|
+
MethodListener.new(response_builder, node_context, dispatcher)
|
|
89
|
+
|
|
90
|
+
dispatcher.dispatch(document.ast)
|
|
91
|
+
response_builder.response
|
|
92
|
+
rescue StandardError
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../support/node_helpers"
|
|
4
|
+
|
|
5
|
+
module RubyLsp
|
|
6
|
+
module Refactor
|
|
7
|
+
# Emits a "Convert to symbol array" code action when the cursor is on an
|
|
8
|
+
# ArrayNode whose every element is a plain SymbolNode.
|
|
9
|
+
#
|
|
10
|
+
# Input: [:foo, :bar, :baz]
|
|
11
|
+
# Output: %i[foo bar baz]
|
|
12
|
+
#
|
|
13
|
+
# Arrays that already use %i[] or %I[] syntax, or that contain non-symbol
|
|
14
|
+
# elements, are ignored.
|
|
15
|
+
class ArrayListener
|
|
16
|
+
include RubyLsp::Requests::Support::Common
|
|
17
|
+
include Support::NodeHelpers
|
|
18
|
+
|
|
19
|
+
# @param response_builder [RubyLsp::ResponseBuilders::CollectionResponseBuilder]
|
|
20
|
+
# @param node_context [RubyLsp::NodeContext]
|
|
21
|
+
# @param dispatcher [Prism::Dispatcher]
|
|
22
|
+
def initialize(response_builder, node_context, dispatcher)
|
|
23
|
+
@response_builder = response_builder
|
|
24
|
+
@node_context = node_context
|
|
25
|
+
|
|
26
|
+
dispatcher.register(self, :on_array_node_enter)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_array_node_enter(node)
|
|
30
|
+
return unless node_covers_cursor?(node)
|
|
31
|
+
return unless convertible_to_symbol_array?(node)
|
|
32
|
+
|
|
33
|
+
emit_convert_to_symbol_array(node)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Returns true when:
|
|
41
|
+
# 1. The array uses bracket syntax (not already %i[] / %I[]).
|
|
42
|
+
# 2. Every element is a plain colon-prefixed SymbolNode.
|
|
43
|
+
# 3. There is at least one element.
|
|
44
|
+
def convertible_to_symbol_array?(node)
|
|
45
|
+
return false if node.elements.empty?
|
|
46
|
+
return false unless node.opening_loc&.slice == "["
|
|
47
|
+
|
|
48
|
+
node.elements.all? do |el|
|
|
49
|
+
el.is_a?(Prism::SymbolNode) && el.opening_loc&.slice == ":"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emit_convert_to_symbol_array(node)
|
|
54
|
+
symbols = node.elements.map(&:unescaped)
|
|
55
|
+
new_text = "%i[#{symbols.join(" ")}]"
|
|
56
|
+
|
|
57
|
+
@response_builder << Interface::CodeAction.new(
|
|
58
|
+
title: "Convert to symbol array",
|
|
59
|
+
kind: Constant::CodeActionKind::REFACTOR_REWRITE,
|
|
60
|
+
edit: single_edit_workspace_edit(node, new_text),
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|