ruby-lsp-refactor
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.
Installation
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"
end
Then run:
bundle install
The 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.
Supported refactorings
Place your cursor anywhere on the relevant construct and open the code-actions
menu (Cmd+. in VS Code / Zed, or your editor's equivalent).
Conditionals
Convert to post-conditional
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?
Convert to block if / Convert to block unless
The reverse — expands a trailing modifier back into a full block.
# Before
user.approve! if user.qualified?
# After
if user.qualified?
user.approve!
end
Convert to unless / Convert to if
Toggles 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!
end
Invert if/else
Negates 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!
end
Convert to early return
Converts 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!
end
Strings
Convert to interpolated string
Upgrades a single-quoted string to double-quotes so you can immediately add
#{} interpolation. Embedded " characters are escaped.
'hello world' → "hello world"
Convert to string array / Convert to bracket array
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"]
Wrap in freeze / Remove freeze
Adds or removes .freeze on a string literal.
"hello" → "hello".freeze
"hello".freeze → "hello"
Collections
Convert to symbol array
Converts a bracket array of plain symbols into a %i[] word array.
[:foo, :bar, :baz] → %i[foo bar baz]
Convert to keyword syntax
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 }
Convert to .flat_map
Collapses a map + flatten / flatten(1) chain.
items.map { |i| i.tags }.flatten(1) → items.flat_map { |i| i.tags }
Convert to .find
Collapses a select + first chain.
users.select { |u| u.admin? }.first → users.find { |u| u.admin? }
Convert to .filter_map
Collapses a map + compact chain.
items.map { |i| i.value }.compact → items.filter_map { |i| i.value }
Variables & constants
Inline variable
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.calculate
Extract constant
Extracts 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
end
Methods & classes
Add parameter
Appends 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)
Convert to keyword arguments
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:)
Convert to attr_accessor
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 :name
Wrap body in rescue
Wraps 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
end
Extract predicate methods
Extracts 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
end
Convert to explicit super
Converts 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)
end
Operators & blocks
Convert to tap
Converts 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
end
Convert && to and / and to &&
Toggles between symbolic and word forms of the logical AND operator.
user.valid? && user.save → user.valid? and user.save
Convert || to or / or to ||
Toggles between symbolic and word forms of the logical OR operator.
a || b → a or b
Simplify raise
Removes 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"
RSpec
Extract to let
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")
end
Convert let to let! / let! to let
Toggles between lazy (let) and eager (let!) memoization.
let(:user) { User.new } → let!(:user) { User.new }
let!(:user) { User.new } → let(:user) { User.new }
Planned refactorings
The items below are on the roadmap but not yet implemented. They are tracked here so the intent is not lost.
Single-file (not yet implemented)
| 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. |
Multi-file
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.
✅ Extract Include File
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
end
🔲 Extract Service Object
Moves 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
end
🔲 Extract Form Object
Extracts 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.
🔲 Extract Policy Class
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?
end
🔲 Combine Functions into Class
When 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}"
end
🔲 Introduce Null Object
When 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_name
🔲 Rename
Renames 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.
🔲 Extract Parameter
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}"
end
🔲 Extract Superclass
Extracts 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
end
🔲 Extract Module
Extracts 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 = "=== End ==="
def generate = "#{format_header}\n...\n#{}"
end
# After — app/concerns/formattable.rb (new file)
module Formattable
def format_header = "=== Report ==="
def = "=== End ==="
end
# After — app/models/report.rb (modified)
class Report
include Formattable
def generate = "#{format_header}\n...\n#{}"
end
🔲 Pull Members Up / Push Members Down
Moves 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.
🔲 Safe Delete
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.
🔲 Extract Partial (Rails)
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 %>
🔲 Extract Include File (generic)
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.
Development
bin/setup # install dependencies
bundle exec rake test # run the test suite
bundle exec rake # lint + test
To 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"
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/tachyons/ruby-lsp-refactor.
License
The gem is available as open source under the terms of the MIT License.