RuboCoping with legacy: Bring your Ruby code up to Standard
You’ll hardly find a Ruby developer who hasn’t heard about RuboCop, the Ruby linter and formatter. Yet still, it’s also not that hard to find a project where code style has not been enforced. Usually, these are large, mature codebases, and often successful ones. But fixing linting and formatting can be a challenge if it wasn’t set up correctly from the get-go. So, if your RuboCop is seeing red, here’s how to fix it!
Disclaimer: This article is being regularly updated with the best, most up-to-date recommendations; take a look at the Changelog section for more info.
In this post, I’ll show you how we at Evil Martians touch up customer codebases in <%= Date.today.year>
: from quick and dirty hacks to proper Standard-enforced style guides, and our own patented way to use Standard and RuboCop configs together. After reading, if you like the proposed configuration, you can find the instructions on how to automatically apply it to your project using the RuboCoping application template.
Style matters
Let’s pretend I have to convince you to follow code style guidelines (I know, I know I don’t have to!)
Here are the arguments I would use:
- Developers understand each other much better when they
speakwrite the same language. - Onboarding new engineers becomes much easier when the code style is standardized.
- Linters help detect and squash bugs in a timely fashion.
- No more “single vs. double quotes” holy wars (double W)!
Enough theory for today, time for practice!
TODO or not TODO
So, you’ve joined a project with no style guide, or a project with a .rubocop.yml
file that was added years ago. You run RuboCop, and you see something like this:
$ bundle exec rubocop
3306 files inspected, 12418 offenses detected
Flocks of noble knights developers have tried to slay the beast fix the offenses, but they’ve all given up. But that doesn’t stop you—you know the magic spell:
$ bundle exec rubocop --auto-gen-config
Added inheritance from `.rubocop_todo.yml` in `.rubocop.yml`.
Created .rubocop_todo.yml.
$ bundle exec rubocop
3306 files inspected, no offenses detected
That was simple! Toss the coin to your…
Let’s take a closer look at what --auto-gen-config
flag does:
- First, it collects and counts all the offenses
- Then, it generates a
.rubocop_todo.yml
where all the current offenses are ignored - And finally, it makes
.rubocop.yml
inherit from.rubocop_todo.yml
This is the way to define the status quo and enforce style checks for new code only. Sounds smart, right? Not exactly.
The way .rubocop_todo.yml
handles “ignores” depends on the cop types and the total number of current offenses:
- For metrics cops (such as
Layout/LineLength
), the limit (Max
) is set to the maximum value for the current codebase. - All cops could be disabled if the total number of offenses hits the threshold (only 15 by default).
So, you end up with an “anything goes” situation, and that defeats the purpose of keeping code style consistent in the first place.
What does that mean for the typical legacy codebase? Most new code will be ignored by RuboCop, too. We’ve made the tool happy, but are we happy with it?
Luckily, there is a way to generate a better TODO config by adding more options to the command:
bundle exec rubocop \
--auto-gen-config \
--auto-gen-only-exclude \
--no-exclude-limit
Here, --auto-gen-only-exclude
force-excludes metrics cops instead of changing their Max
value, and --no-exclude-limit
prevents cops from being completely disabled.
Now your .rubocop_todo.yml
file won’t affect your new files or entirely new offenses in the old ones.
RuboCop not only helps with style—it also saves you from common mistakes that can break your code in production, as posed by these questions:
- What if you had some bugs and had ignored the corresponding cops in your TODO config?
- What are the cops that should never be ignored?
Let me introduce the RuboCop strict configuration pattern.
You shall not pass: introducing .rubocop_strict.yml
There are a handful of cops that must be enabled for all the files independently of .rubocop_todo.yml
. For example:
Lint/Debugger
—don’t leave debugging calls (e.g.,binding.pry
).RSpec/Focus
(fromrubocop-rspec
)—don’t forget to clear focused tests (to make sure CI runs the whole test suite).
We put cops like this into a .rubocop_strict.yml
configuration file like this:
Lint/Debugger: # don't leave binding.pry or debugger
Enabled: true
Exclude: []
RSpec/Focus: # run ALL tests on CI
Enabled: true
Exclude: []
Rails/Output: # Don't leave puts-debugging
Enabled: true
Exclude: []
Rails/FindEach: # each could severely affect the performance, use find_each
Enabled: true
Exclude: []
Rails/UniqBeforePluck: # uniq.pluck and not pluck.uniq
Enabled: true
Exclude: []
Then, we place the Strict config right after the TODO config file in our base .rubocop.yml
configuration file:
inherit_from:
- .rubocop_todo.yml
+ - .rubocop_strict.yml
The Exclude: []
is crucial here: even if our .rubocop_todo.yml
contained exclusions for strict cops, we nullify them here, thus, re-activating these cops for all the files.
One Standard to rule them all
One of the biggest problems in adopting a code style is convincing everyone on the team to always use double-quotes for strings, or to add trailing commas to multiline arrays, or to <choose-your-own-controversal-style-rule>? We’re all well familiar with bikeshedding.
RuboCop provides a default configuration based on the Ruby Style Guide. And you know what? It’s hard to find a project which follows all of the default rules; there are always reconfigured or disabled cops in .rubocop.yml
.
And that’s okay. RuboCop’s default configuration is not a golden standard, and it was never meant to be the “one style to fit them all”.
Should the Ruby community have that “one style” at all? It seems that yes, we need it.
I think the main reason for that is the popularity of auto-formatters in other programming languages: JavaScript, Go, Rust, Elixir. Auto-formatters are usually very strict and allow for almost no or zero configuration. And developers have gotten used to that! People like writing code without worrying about indentation, brackets, and spaces; let the robots sort it all out!
Thankfully, Ruby’s ecosystem has got you covered: there is a project called Standard, which claims to be the one and only Ruby style guide.
From a technical point of view, Standard is a wrapper over RuboCop with its custom configuration and CLI (standard
).
Standard also supports TODOs with bundle exec standardrb --generate-todo
. This will create a .standard_todo.yml
that contains a mapping of source files to the cop names which produce offenses for these files. When you run Standard in the future it will ignore these errors.
However, if you prefer to keep all your eggs in one basket and look for a way to integrate your existing .rubocop_todo.yml
with your Standard workflow, look no further.
We can still use Standard as a style guide while continuing to use RuboCop as a linter and formatter!
For that, we can use RuboCop’s inherit_gem
directive:
# .rubocop.yml
# We want Exclude directives from different
# config files to get merged, not overwritten
inherit_mode:
merge:
- Exclude
require:
# Standard's config uses custom cops,
# so it must be loaded along with custom Standard gems
- standard
- standard-custom
- standard-performance
# rubocop-performance is required when using Performance cops
- rubocop-performance
inherit_gem:
standard: config/base.yml
# You can also choose a Ruby-version-specific config
# standard: config/ruby-3.0.yml
# Standard plugins must be loaded separately (since v1.28.0)
standard-performance: config/base.yml
standard-custom: config/base.yml
inherit_from:
- .rubocop_todo.yml
- .rubocop_strict.yml
# Sometimes we enable metrics cops
# (which are disabled in Standard by default)
#
# Metrics:
# Enabled: true
# Global options, like Ruby version
AllCops:
SuggestExtensions: false
TargetRubyVersion: 3.2
This is the configuration I use in most of my OSS and commercial projects. I can’t say I agree with all the rules, but I definitely like it more than RuboCop’s default. A tiny trade-off if you think about the benefits of no more style arguments.
Don’t forget to add standard
to your Gemfile and freeze its minor version to avoid unexpected failures during upgrades:
gem "standard", "~> 1.28", require: false
Although the approach above allows you to tinker with the Standard configuration, I wouldn’t recommend doing so. Use this flexibility to extend the default behavior, not to change it!
Beyond the Standard
Standard includes the rubocop-performance
plugin by default and provides the standard-rails extension for Ruby on Rails projects. Adding standard-rails
to our configuration is as simple as adding the following lines to the .rubocop.yml
file:
require:
- standard
- standard-custom
- standard-performance
+ - rubocop-rails
- rubocop-performance
inherit_gem:
standard: config/base.yml
standard-performance: config/base.yml
standard-custom: config/base.yml
+ standard-rails: config/base.yml
RuboCop has a lot of plugins distributed as separate gems: rubocop-rspec
, rubocop-minitest
, rubocop-performance
, rubocop-graphql
, rubocop-md
, to name a few. We usually include some of them (at least rubocop-rspec
or rubocop-minitest
) in our configuration.
For each plugin, we keep a separate YAML file in the .rubocop/
folder (to avoid polluting the project root): .rubocop/rspec.yml
, .rubocop/graphql.yml
, etc. Only the .rubocop_todo.yml
stays in the project’s root—it’s better to keep it in the default location familiar to Ruby developers.
Inside the base config we add these files to inherit_from
:
inherit_from:
- .rubocop/rspec.yml
- .rubocop_todo.yml
- .rubocop/strict.yml
There is no standard RSpec configuration, so we had to figure out our own: .rubocop/rspec.yml
.
We also usually enable a select few custom cops, for example, Lint/Env
. We use a .rubocop/custom.yml
configuration file for this:
# .rubocop/custom.yml
require:
# Cops source code lives in the lib/ folder
- ./lib/rubocop/cops
Lint/Env:
Enabled: true
Include:
- "**/*.rb"
Exclude:
- "**/config/environments/**/*"
- "**/config/application.rb"
- "**/config/environment.rb"
- "**/config/puma.rb"
- "**/config/boot.rb"
- "**/spec/*_helper.rb"
- "**/spec/**/support/**/*"
- "lib/generators/**/*"
In the end, our typical RuboCop configuration for Rails projects looks like this👇
# .rubocop.yml
inherit_mode:
merge:
- Exclude
require:
- standard
- standard-custom
- standard-performance
- rubocop-performance
- rubocop-rails
inherit_gem:
standard: config/base.yml
standard-performance: config/base.yml
standard-custom: config/base.yml
standard-rails: config/base.yml
inherit_from:
- .rubocop/rspec.yml
- .rubocop/custom.yml
- .rubocop_todo.yml
- .rubocop/strict.yml
AllCops:
SuggestExtensions: false
TargetRubyVersion: 3.2
Feel free to use this as inspiration for your projects that are in need of some tough RuboCop love.
Sticking with RuboCop or switching to Standard CLI?
Standard is designed to be a standalone tool, an opinionated (“no decisions to make”) Ruby code linter and formatter. If you take a look at its documentation, you can see that the recommended way of standardizing the codebase is to use the standardrb
CLI along with a .standard.yml
configuration file. So, why do we recommend using RuboCop in the first place?
For a long time (the first version of this post was published in 2020), the main argument was the lack of extension support in Standard. However, recent versions have introduced the extend_config
configuration option, and this has made it possible to load additional RuboCop configurations from separate YAML files. It’s a game-changer! Here’s how we can migrate the configuration we introduced above to Standard:
# .standard.yml
parallel: true
format: progress
plugins:
- standard-rails
extend_config:
- .rubocop/rspec.yml
- .rubocop/custom.yml
Unfortunately, it’s not fully compatible with the .rubocop.yml
file we had before: .rubocop_todo.yml
and .rubocop/strict.yml
are missing. This is because Standard’s extend_config
option does not allow overriding configuration; it only supports adding new cops.
We can replace .rubocop_todo.yml
with .standard_todo.yml
for sure. But there is no replacement for .rubocop/strict.yml
😕
Similarly, if you decide to go slightly off the standard path, and, for example, try to disable a cop globally in the custom config file, this wouldn’t work.
Thus, we still prefer to use rubocop
over standardrb
in larger projects with a lot of history. For greenfield projects, standardrb
is a viable choice, especially if you manage to automate its execution, so developers don’t need to think which command to run, bin/rubocop
or bin/standardrb
.
RuboCop vs. Bundler vs. Gemfile
The standard way of adding RuboCop to a project is adding it as a dependency to the Gemfile:
# Gemfile
group :development do
# Technically, we don't need to specify "rubocop" here,
# since "standard" declares it as a dependency
gem "rubocop", "~> 1.0"
gem "standard", "~> 1.28"
end
With every added RuboCop plugin, the Gemfile grows. What’s wrong with a large number of dependencies in a Gemfile? Two things:
-
Time. More precisely, the time it takes Bundler to verify the dependencies. And since we run
rubocop
(orstandardrb
) viabundle exec
, the overhead also affects the time to lint the code. -
Readability. The less you scroll through the Gemfile to learn about dependencies the better. A dozen lines of code is probably not a big deal but still can be considered as a point for improvement.
How can we refactor a Gemfile to solve these problems? We can extract RuboCop dependencies into a separate Gemfile! Since linter is a static analysis tool and not required in runtime, we can freely keep a separate manifest (a Gemfile) and a lock-file for it.
Let’s create a Gemfile for our RuboCop configuration and put it in the gemfiles/rubocop.gemfile
file:
# gemfiles/rubocop.gemfile
source "https://rubygems.org" do
gem "rubocop-rspec"
gem "standard", "~> 1.28"
gem "standard-rails"
end
Now we have two options: we can include this Gemfile in the main one via the eval_gemfile "gemfiles/rubocop.gemfile"
expression, or we can use it independently. In the second case, we can create a simple bin/rubocop
executable to automatically install the dependencies and use the correct Gemfile:
#!/bin/bash
cd $(dirname $0)/..
export BUNDLE_GEMFILE=./gemfiles/rubocop.gemfile
bundle check > /dev/null || bundle install
bundle exec rubocop $@
As a positive side-effect, we can now run RuboCop locally even if using Docker for development. No need to install all the project dependencies; having (any version of) Ruby installed is sufficient to lint the code. That’s the third reason to extract RuboCop dependencies into a separate Gemfile.
Rubocoping application template
To help you adopt the standardized RuboCop configuration, we’ve created an interactive generator that makes introducing linting and style to your Rails application as simple as running a single command:
rails app:template LOCATION="https://railsbytes.com/script/V4YsLQ"
For non-Rails projects, you can use Ruby Bytes:
rbytes install https://railsbytes.com/script/V4YsLQ
Here is an example run of the generator:
The source code of the generator can be found in the evilmartians/rubocoping-generator repo.
RuboCop plays a vital role in the Ruby world and will remain #1 for linting and formatting code for quite a long time. Don’t ignore RuboCop; write code in style 😎
Don’t hesitate to drop us a line if you want us to take a look at your codebase and help you set up the best Ruby (or Go, or JavaScript, or TypeScript, or Rust…) practices.
Changelog
2.1.0 (2023-09-13)
- Upgrade to use
standard-rails
.
2.0.1 (2023-04-23)
- Upgrade to Standard v1.28.0 with
standard-performance
andstandard-custom
extracted.
2.0.0 (2023-03-24)
- Added
bin/rubocop
vs.bin/standardrb
section. - Added
gemfiles/rubocop.gemfile
andbin/rubocop
. - Added rubocoping-generator.
1.3.0 (2023-03-15)
-
Standard now supports configuration extensions.
-
Changed to use the
.rubocop/*.yml
approach by default.
1.2.1 (2022-11-28)
- Added
TargetRubyVersion
to the example configuration. - Added a note about Standard’s version-specific configs.
- Added a link to
rubocop-gradual
. - Replaced
--exclude-limit=10000
with-no-exclude-limit
(added in RuboCop v1.3.7).
1.2.0 (2022-03-22)
- Extracted custom cops into their own configuration.
- Added a note about the
.rubocop/
folder. - Added rubocop-graphql to the list of popular plugins.
1.1.3 (2021-07-06)
- Upgraded to Standard 1.0.
1.1.2 (2021-01-06)
- Mentioned Standard support for
--generate-todo
flag. Thanks to Jennifer Konikowski, one of the StandardRb maintainers, for pointing that out to us.
1.1.0 (2020-03-27)
- Fixed TODO config generation command.
- Added note about rubocop_lineup and pronto-rubocop.
- Added note about RuboCop defaults survey.