Beta software. This gem is under active development. Refactorings may produce incorrect output in edge cases. A significant portion of the implementation was written with AI assistance β please review generated edits before committing them. Bug reports and corrections are very welcome.
A ruby-lsp add-on that provides AST-driven refactoring code actions natively inside any LSP-supported editor (VS Code, Zed, Neovim, RubyMine, etc.).
All refactors are powered by the Prism parser and operate on the real AST β no regex substitutions.
Add the gem to your project's Gemfile (it only needs to be available to the
language server, so the :development group is the right place):
group :development do
gem "ruby-lsp-refactor"
endThen run:
bundle installThe add-on is discovered and activated automatically by ruby-lsp β no further configuration is required.
Note on upstream overlap. ruby-lsp already provides "Refactor: Extract Variable", "Refactor: Extract Method", and "Refactor: Toggle block style" natively. This add-on intentionally does not duplicate those actions β place your cursor on any expression or block and they will appear alongside the refactorings listed below.
Place your cursor anywhere on the relevant construct and open the code-actions
menu (Cmd+. in VS Code / Zed, or your editor's equivalent).
Collapses a single-statement if or unless block into a trailing modifier.
# Before
if user.qualified?
user.approve!
end
# After
user.approve! if user.qualified?The reverse β expands a trailing modifier back into a full block.
# Before
user.approve! if user.qualified?
# After
if user.qualified?
user.approve!
endToggles between if and unless on a block conditional with no else branch.
When the predicate already starts with !, the negation is stripped
automatically.
# Before
if !user.banned?
user.login!
end
# After β negation stripped
unless user.banned?
user.login!
endNegates the condition and swaps the two branches. Double-negation (!!) is
cancelled automatically.
# Before
if user.admin?
grant!
else
deny!
end
# After
if !user.admin?
deny!
else
grant!
endConverts a guard if block at the top of a method into a return unless
statement, eliminating unnecessary nesting. The method body must have no
else branch and the if must be the first statement.
# Before β cursor on the if
def charge_purchase(order)
if order.fulfilled?
OrderChargeConfirmation.new(order).create!
end
end
# After
def charge_purchase(order)
return unless order.fulfilled?
OrderChargeConfirmation.new(order).create!
endUpgrades a single-quoted string to double-quotes so you can immediately add
#{} interpolation. Embedded " characters are escaped.
'hello world' β "hello world"Converts between a bracket array of plain strings and %w[] syntax.
["foo", "bar", "baz"] β %w[foo bar baz]
%w[foo bar baz] β ["foo", "bar", "baz"]Adds or removes .freeze on a string literal.
"hello" β "hello".freeze
"hello".freeze β "hello"Converts a bracket array of plain symbols into a %i[] word array.
[:foo, :bar, :baz] β %i[foo bar baz]Converts hash-rocket pairs whose keys are plain symbols into modern keyword syntax. Mixed hashes are handled gracefully β only eligible pairs are converted.
{ :name => "Alice", :age => 30 } β { name: "Alice", age: 30 }Collapses a map + flatten / flatten(1) chain.
items.map { |i| i.tags }.flatten(1) β items.flat_map { |i| i.tags }Collapses a select + first chain.
users.select { |u| u.admin? }.first β users.find { |u| u.admin? }Collapses a map + compact chain.
items.map { |i| i.value }.compact β items.filter_map { |i| i.value }Removes a local variable assignment and replaces every subsequent read with the original right-hand-side expression.
# Before β cursor on the assignment
result = user.calculate
puts result
log result
# After
puts user.calculate
log user.calculateExtracts a literal value (integer, float, string, symbol) inside a class or module into a named constant at the top of the enclosing body.
# Before β cursor on 100
class Processor
def run
items.first(100)
end
end
# After
class Processor
EXTRACTED_CONSTANT = 100
def run
items.first(EXTRACTED_CONSTANT)
end
endAppends a new_param placeholder to a method's parameter list. Parentheses
are added automatically when the method has none.
def greet(name) β def greet(name, new_param)
def greet β def greet(new_param)Rewrites required positional parameters to keyword arguments. Optional parameters, rest args, and block parameters are left unchanged.
def create(name, age) β def create(name:, age:)Detects an attr_reader paired with a canonical manual writer
(def name=(val); @name = val; end) and collapses them into a single
attr_accessor.
# Before β cursor on either line
attr_reader :name
def name=(val)
@name = val
end
# After
attr_accessor :nameWraps a method's entire body in a rescue StandardError => e clause with a
raise placeholder so you can fill in the error handling without accidentally
swallowing exceptions.
# Before
def call
do_thing
end
# After
def call
do_thing
rescue StandardError => e
raise
endExtracts each operand of a compound && or || expression that is the sole
statement in a method into its own private predicate method. The generated
names predicate_1? / predicate_2? are placeholders β rename them to
reflect intent.
# Before β cursor on the compound expression
def eligible_for_return?
expired_orders.exclude?(self) && self.value > MINIMUM_RETURN_VALUE
end
# After
def eligible_for_return?
predicate_1? && predicate_2?
end
private
def predicate_1?
expired_orders.exclude?(self)
end
def predicate_2?
self.value > MINIMUM_RETURN_VALUE
endConverts a bare super (which forwards all arguments implicitly) into an
explicit super(param1, param2, ...) using the enclosing method's parameter
names.
def initialize(name, age)
super β super(name, age)
endConverts a sequence of method calls on the same receiver followed by a bare
return of that receiver into an Object#tap block, grouping the operations
and removing the explicit return.
# Before β cursor anywhere in the method
def do_something
obj.do_first_thing
obj.do_second_thing
obj.do_third_thing
obj
end
# After
def do_something
obj.tap do |o|
o.do_first_thing
o.do_second_thing
o.do_third_thing
end
endToggles between symbolic and word forms of the logical AND operator.
user.valid? && user.save β user.valid? and user.saveToggles between symbolic and word forms of the logical OR operator.
a || b β a or bRemoves the redundant RuntimeError class from a two-argument raise or
fail call. RuntimeError is Ruby's default exception class and need not be
stated explicitly.
raise RuntimeError, "oops" β raise "oops"Moves a local variable assignment inside an it/specify/example/scenario
block into a let declaration above the example.
# Before β cursor on the assignment
it "logs in" do
user = User.new(name: "Alice")
expect(user.name).to eq("Alice")
end
# After
let(:user) { User.new(name: "Alice") }
it "logs in" do
expect(user.name).to eq("Alice")
endToggles between lazy (let) and eager (let!) memoization.
let(:user) { User.new } β let!(:user) { User.new }
let!(:user) { User.new } β let(:user) { User.new }The items below are on the roadmap but not yet implemented. They are tracked here so the intent is not lost.
| Refactoring | Description |
|---|---|
| Introduce field | Extracts an expression inside a method into an instance variable (@name), inserting the assignment at the top of the method or into initialize. |
These refactorings create new files and/or update call sites across the
project. The ones marked β
are already implemented using document_changes
in the WorkspaceEdit response, which lets a single code action atomically
create files and edit multiple documents. The ones marked π² require
workspace-level index support or are pending implementation.
Extracts a top-level module or class into its own file and replaces it
with a require_relative statement. Offered when the cursor is on a module or
class that coexists with other top-level statements in the same file.
# Before β app/models/user.rb (cursor on the module)
module Greetable
def greet = "hello"
end
class User
include Greetable
end
# After β app/models/greetable.rb (new file, created automatically)
# frozen_string_literal: true
module Greetable
def greet = "hello"
end
# After β app/models/user.rb (modified)
require_relative "greetable"
class User
include Greetable
endMoves callback logic out of a controller into a dedicated service object file.
Addresses the Rails antipattern of using after_action callbacks for
operations that depend on the success of the triggering action.
# Before β app/controllers/users_controller.rb
class UsersController < ApplicationController
after_action :send_confirmation_email, only: [:create]
def create
@user = User.create!(user_params)
end
end
# After β app/services/user_confirmation_service.rb (new file)
class UserConfirmationService
def initialize(user) = @user = user
def call
AccountCreationMailer.new(@user).deliver! if @user.persisted?
end
end
# After β app/controllers/users_controller.rb (modified)
class UsersController < ApplicationController
def create
@user = User.create!(user_params)
UserConfirmationService.new(@user).call
end
endExtracts a model that uses accepts_nested_attributes_for into a plain Ruby
form object that includes ActiveModel::Model, making the form flat,
explicitly validated, and easy to test.
Creates a new file under app/forms/ and updates the controller and view to
use the form object instead of the model directly.
When a method contains more than two or three compound conditions, extracts them all into a dedicated policy class with individual predicate methods. Each predicate becomes a public method on the policy, making them independently testable without stubs.
# Before β single method with many conditions
def eligible_for_return?
not_expired? && over_minimum_value? && customer_not_fraudulent?
end
# After β app/policies/return_eligibility_policy.rb (new file)
class ReturnEligibilityPolicy
def initialize(order) = @order = order
def eligible?
not_expired? && over_minimum_value? && customer_not_fraudulent?
end
def not_expired? = Order.expired_orders.exclude?(@order)
def over_minimum_value? = @order.value > Order::MINIMUM_RETURN_VALUE
def customer_not_fraudulent? = @order.user.not_fraudulent?
end
# After β calling code (modified)
def eligible_for_return?
ReturnEligibilityPolicy.new(self).eligible?
endWhen several methods in a file all take the same object as their first argument, extracts them into a new class where that object becomes an injected dependency. Implements the Combine Functions into Class pattern.
# Before β repeated argument is a smell
def format_name(user) = "#{user.first_name} #{user.last_name}"
def greeting(user) = "Hello, #{format_name(user)}"
def farewell(user) = "Goodbye, #{format_name(user)}"
# After β app/presenters/user_presenter.rb (new file)
class UserPresenter
def initialize(user) = @user = user
def format_name = "#{@user.first_name} #{@user.last_name}"
def greeting = "Hello, #{format_name}"
def farewell = "Goodbye, #{format_name}"
endWhen a method guards against a nil association with an if check before
delegating to it, extracts a null object class that implements the same
interface with safe default behaviour, removing the conditional entirely.
# Before
if @user.has_address?
@user.address.street_name
else
"Unknown street"
end
# After β app/models/null_address.rb (new file)
class NullAddress
def street_name = "Unknown street"
end
# After β app/models/user.rb (modified)
class User
def address = @address || NullAddress.new
end
# After β calling code (no conditional needed)
@user.address.street_nameRenames a method, class, module, constant, or local variable and updates every reference to it across the entire project. Requires the ruby-lsp index to locate all usages safely.
Extracts an expression inside a method body into a new parameter, adding it to the method signature and updating every call site in the project to pass the extracted value.
# Before
def greet
"Hello, #{DEFAULT_NAME}"
end
# After β signature and all call sites updated
def greet(name = DEFAULT_NAME)
"Hello, #{name}"
endExtracts selected methods from a class into a new superclass and makes the original class inherit from it. Creates a new file for the superclass.
# Before β app/models/animal.rb
class Animal
def breathe = "breathing"
def eat = "eating"
def speak = raise NotImplementedError
end
# After β app/models/living_thing.rb (new file)
class LivingThing
def breathe = "breathing"
def eat = "eating"
end
# After β app/models/animal.rb (modified)
class Animal < LivingThing
def speak = raise NotImplementedError
endExtracts selected methods from a class into a new module and adds an
include statement. Creates a new file for the module.
# Before
class Report
def format_header = "=== Report ==="
def format_footer = "=== End ==="
def generate = "#{format_header}\n...\n#{format_footer}"
end
# After β app/concerns/formattable.rb (new file)
module Formattable
def format_header = "=== Report ==="
def format_footer = "=== End ==="
end
# After β app/models/report.rb (modified)
class Report
include Formattable
def generate = "#{format_header}\n...\n#{format_footer}"
endMoves methods between a class and its superclass. "Pull up" moves a method from a subclass to the superclass; "push down" moves it from the superclass into one or more subclasses. Both operations update all affected files.
Deletes a method, class, or constant only after verifying it has no usages anywhere in the project. Requires the ruby-lsp index to confirm the symbol is unreferenced before removing it.
Extracts a fragment of an ERB view template into a new partial file and
replaces the original fragment with a render call.
<%# Before β app/views/users/show.html.erb %>
<div class="profile">
<h1><%= @user.name %></h1>
<p><%= @user.bio %></p>
</div>
<%# After β app/views/users/_profile.html.erb (new file) %>
<div class="profile">
<h1><%= user.name %></h1>
<p><%= user.bio %></p>
</div>
<%# After β app/views/users/show.html.erb (modified) %>
<%= render "profile", user: @user %>Extracts an arbitrary block of Ruby code (not necessarily a named module or
class) into a new file and replaces it with a require_relative statement.
The β
variant above handles the named module/class case automatically; this
generic form would handle any selected lines.
bin/setup # install dependencies
bundle exec rake test # run the test suite
bundle exec rake # lint + testTo try the add-on against a local project without publishing to RubyGems, add
a path reference to that project's Gemfile:
gem "ruby-lsp-refactor", path: "/path/to/ruby-lsp-refactor"Bug reports and pull requests are welcome on GitHub at https://github.com/tachyons/ruby-lsp-refactor.
The gem is available as open source under the terms of the MIT License.