Faster RuboCop runs for Rails apps
Tags: ruby, rails, linters, docker
Published: 2022-04-12 18:07:08

Recently, Nate Berkopec shared an interesting observation: running `bundle exec whatever` could take seconds to boot if the Gemfile is huge (even when the executable itself requires a handful of dependencies).

{% embed https://twitter.com/nateberkopec/status/1494685271387881472 %}

That could be explained by the fact that Bundler has to verify the `Gemfile.lock` file consistency (all the gems are installed). Thus, that's **an expected behaviour** (that doesn't mean we shouldn't try to improve it; see, for example, Matthew Draper's [Gel](https://github.com/gel-rb/gel)).

Rails developers usually put all the deps in the `Gemfile`, including dev tools, such as [RuboCop](https://github.com/rubocop/rubocop). RuboCop is a linter, and **linters must be fast**. RuboCop itself complies with this statement but running it via Bundler may not.

How can we overcome this? Using a separate Gemfile!

I've been using this technique for a long time for gems development—to speed up CI RuboCop runs (by installing only the linter dependencies). Here is my [typical `rubocop.gemfile`](https://github.com/anycable/anycable/blob/master/gemfiles/rubocop.gemfile):

```ruby
# gemfiles/rubocop.gemfile
source "https://rubygems.org" do
  gem "rubocop-md", "~> 1.0"
  gem "rubocop-rspec"
  gem "standard", "~> 1.0"
end
```

To use it with Bundler, we need to specify the `BUNDLER_GEMFILE` env variable:

```sh
# first, install the deps
BUNDLE_GEMFILE=gemfiles/rubocop.gemfile bundle install

# then, run the executable
BUNDLE_GEMFILE=gemfiles/rubocop.gemfile bundle exec rubocop
```

This verbose approach works well enough for machines (CI), but not for humans: maintaining a separate lockfile and using env vars in development is far from the perfect user experience.

For Rails applications development, we came up with the following trick to run commands backed by custom gemfiles—adding a simple `bin/whatever` wrapper. Here is our `bin/rubocop`:

```sh
#!/bin/bash

cd $(dirname $0)/..

export BUNDLE_GEMFILE=./gemfiles/rubocop.gemfile
bundle check > /dev/null || bundle install

bundle exec rubocop $@
```

The _magic_ `$@` argument proxies everything you pass to `bin/rubocop`, thus, making this wrapper _quack_ like RuboCop.

We also do `bundle check || bundle install` to make sure all the deps are present (so, you don't need to run `bundle install` yourself).

That's it.

**P.S.** Why not use inline gemfiles (as [Xavier Noria suggested](https://twitter.com/fxn/status/1494763178391224323))? We could write our `bin/rubocop` like this:

```ruby
require 'bundler/inline'

gemfile(true, quiet: true) do
  gem "rubocop-md", "~> 1.0"
  gem "rubocop-rspec"
  gem "standard", "~> 1.0"
end

require 'rubocop'
RuboCop::CLI.new.run
```

However, with this approach, there is no lockfile at all. We want to make sure everyone is using the same versions of dependencies (to avoid "works on my computer" situations). Of course, we can use the exact version in the `gemfile do ... end` block, but, IMO, managing deps with Bundler is more convenient (e.g., you can run `bundle update`).

**P.P.S.** One of the benefits of this approach is the ability to run linters (and other tools, e.g., [Kuby](https://evilmartians.com/chronicles/kubing-rails-stressless-kubernetes-deployments-with-kuby#docker-for-development-vs-kuby-for-deployment)) locally while using [Docker for application development](https://evilmartians.com/chronicles/ruby-on-whales-docker-for-ruby-rails-development); no need to spin up containers to run RuboCop. It's especially helpful if you want to use Git hooks or editor integrations.