RuboCoping with legacy: Bring your Ruby code up to Standard

Cover for 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 speak write 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 (from rubocop-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:

  1. Time. More precisely, the time it takes Bundler to verify the dependencies. And since we run rubocop (or standardrb) via bundle exec, the overhead also affects the time to lint the code.

  2. 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:

RuboCoping generator in action

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 and standard-custom extracted.

2.0.0 (2023-03-24)

  • Added bin/rubocop vs. bin/standardrb section.
  • Added gemfiles/rubocop.gemfile and bin/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.

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.