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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-06-15
4
+
5
+ - Initial release
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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Lsp
5
+ module Refactor
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "refactor/version"
4
+
5
+ module Ruby
6
+ module Lsp
7
+ module Refactor
8
+ class Error < StandardError; end
9
+ # Your code goes here...
10
+ end
11
+ end
12
+ end
@@ -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