---
title: 'Rubocop: Custom Cops for Custom Needs'
teaser: 'Parsers, grepping, ASCII art, and how that helps you automating boring stuff.

  '
tags: rubocop,linters,rails,ruby
author: Matheus Richard
published_on: 2021-10-05
---

I'm working on a project that heavily relies on feature flags. Whenever we add a new feature or fix
a bug, we add a flag for it. Here's how that looks.

We list our flags in a YAML file:

```yml
# config/feature_flags.yml
two_factor_authentication: "Enabled 2-factor auth for all users"
fix_1234: "Check for nils on User#can_vote?"
# ...
```

And we use them in our code:

```ruby
class User
  def can_vote?
    if Feature.enabled?(:fix_1234)
      age.present? && age >= 16
    else
      age >= 16
    end
  end
end
```

This is nice because we can toggle the flags in production and fix bugs or have different features available for different clients.

## Cleaning up flags

If flags refer to bugfixes (like the example above), when QA approves them and the patch goes to
production, we have to clean the flags up.

```diff
# config/feature_flags.yml
two_factor_authentication: "Enabled 2-factor auth for all users"
-fix_1234: "Check for nils on User#can_vote?"
# ...
```

```diff
 class User
   def can_vote?
-    if Feature.enabled?(:fix_1234)
-      age.present? && age >= 16
-    else
-      age >= 16
-    end
+    age.present? && age >= 16
   end
 end
```

While this works well, it's easy to delete flags from the `.yml` file and forget
to delete their usage somewhere in the code. Test coverage helps here, but what
if we could do some static checking?

That's when Rubocop comes to play. We can create a custom cop that checks that for us!

The principle is simple: We need to search for calls like `Feature.enabled?(<some-flag>)` and check
if that flag exists in our YAML file. We _could_ do this by grepping, but it would be hard since
code can vary in style (indentation, use or lack of parentheses, etc.).

What we're going to do is to search for patterns directly in the parsed code. It's like grepping,
but on the AST.

## Grepping the _what_?

When Ruby reads your code, it transforms it from plain text to a data structure
called [Abstract Syntax Tree (AST)]. It is basically a tree that represents how
Ruby will evaluate your code.

Let's look at an AST for the expression `3 * 5 + 1`:

<figure>
  <pre>
    <code>
               CODE                               AST
               ‾‾‾‾                               ‾‾‾
           ___________      ________             ( + )
          | 3 * 5 + 1 | => | PARSER ｜  =>       /   \
           ‾‾‾‾‾‾‾‾‾‾‾      ‾‾‾‾‾‾‾‾         ( * )  ( 1 )
                                             /   \
                                          ( 3 )  ( 5 )
    </code>
  </pre>
  <figcaption>Figure 1 - Code to AST</figcaption>
<figure>

While ASCII art is fun, it's definitely not practical to textually represent
trees this way. A better way to do it is to use [S-expressions]. This is
straightforward if you already know [LISP].

The S-expression representation for the tree in `Figure 1` could look like this:

```lisp
(+ (* 3 5) 1)
```

<small>Tip: If you want to learn more about how languages (especially interpreted ones) work, check out the
**amazing** book [Crafting Interpreters](https://craftinginterpreters.com/) by [Bob
Nystrom](https://twitter.com/munificentbob).</small>

## Oh, ok. Grepping the AST

_Getting back to our topic..._ Rubocop allows us to grep the AST with S-expressions in the same fashion
that we're used to with regexes. So, we have to find what's the S-expression for our pattern
`Feature.enabled?(<some-flag>)`. Here's the best part: **we don't need to know this from the top of
our heads**. The gem called [`parser`], which ships with Rubocop, does the job for us:

```lisp
$ ruby-parse -e "Feature.enabled?(:some_flag)"
(send
  (const nil :Feature) :enabled?
  (sym :some_flag))
```

Here we're using a hardcoded symbol `:some_flag`, but we're going to swap it out to
`$_`, which means that Rubocop will capture the symbol value and yield it for
us. Here's the final pattern we're going to use to search our code:

```lisp
(send
  (const nil :Feature) :enabled?
  (sym $_))
```

## Actually writing a cop

To create a custom Rubocop cop, we create a class that subclasses `RuboCop::Cop::Base`. Then we
need to hop on one of the hooks Rubocop runs while reading our code, like
`on_class`, `on_if`, `on_send`. In this case, we're going to use `on_send`:

```ruby
module CustomCops
  class UnknownFeatureFlag < RuboCop::Cop::Base
    def on_send(node)
      # do stuff with the AST node
    end
  end
end
```

Now we define a custom matcher for the pattern we specified earlier using
Rubocop's macro [`def_node_matcher`]. It will filter out all nodes that we're not
interested in. Note how the matcher yields back the captured symbol to the block
so that we can use it.

```ruby
module CustomCops
  class UnknownFeatureFlag < RuboCop::Cop::Base
    def_node_matcher :on_feature_flag, <<~PATTERN
      (send (const nil :Feature) :enabled? (sym $_))
    PATTERN

    RESTRICT_ON_SEND = [:enabled?].freeze # optimization: don't call `on_send` unless
                                          # the method name is in this list

    def on_send(node)
      on_feature_flag(node) do |flag|
        # do stuff with the flag
      end
    end
  end
end
```

Now we check if this flag exists in our file and register an offense if it doesn't:

```ruby
module CustomCops
  class UnknownFeatureFlag < RuboCop::Cop::Base
    def_node_matcher :on_feature_flag, <<~PATTERN
      (send (const nil :Feature) :enabled? (sym $_))
    PATTERN

    MSG = "Unknown feature flag `%<flag>s`".freeze
    FEATURE_FLAGS = YAML.load_file("config/feature_flags.yml").keys
    RESTRICT_ON_SEND = [:enabled?].freeze # optimization: don't call `on_send` unless
                                          # the method name is in this list

    def on_send(node)
      on_feature_flag(node) do |flag|
        next if FEATURE_FLAGS.include?(flag.to_s) # known flag, move on

        register_offense(node, flag)
      end
    end

    private

    def register_offense(node, flag)
      message = format(MSG, flag: flag)

      add_offense(node, message: message)
    end
  end
end
```

## It's alive!

That's it! We can require our custom class in `.rubocop.yml` and run it along with other cops:

```yaml
# .rubocop.yml
require:
  - ./lib/custom_cops/unknown_feature_flag.rb
```

Or run it standalone:

```sh
rubocop -r ./lib/custom_cops/unknown_feature_flag.rb --only CustomCops/UnknownFeatureFlag
....F
app/secret_file.rb:45:10: C: CustomCops/UnknownFeatureFlag: Unknown feature flag the_cake_is_a_lie
      if Feature.enabled?(:the_cake_is_a_lie)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
```

What we did was just the tip of the iceberg. We could even make our cop
[autocorrectable] to delete old code. It could rewrite something like

```ruby
def foo
  if Feature.enabled?(:some_flag_we_cleaned_up)
    new_code
  else
    old_code
  end
end
```

as

```ruby
def foo
  new_code
end
```

## Lessons learned

The major point here is not learning _how to write_ a custom Rubocop cop, but
knowing how writing one can save you/your team time by avoiding manual checks.

For more detail on how to write, test, and bundle custom cops in a gem, refer
to this [fantastic post] by Evil Martians and [Rubocop's official guide].

[Abstract Syntax Tree (AST)]: https://www.twilio.com/blog/abstract-syntax-trees
[S-expressions]: https://gigamonkeys.com/book/syntax-and-semantics.html#whats-with-all-the-parentheses
[LISP]: https://stopa.io/post/265
[`parser`]: https://github.com/whitequark/parser
[`def_node_matcher`]: https://www.rubydoc.info/gems/rubocop-ast/RuboCop%2FAST%2FNodePattern%2FMacros:def_node_matcher
[autocorrectable]: https://docs.rubocop.org/rubocop/development.html#auto-correct
[fantastic post]: https://evilmartians.com/chronicles/custom-cops-for-rubocop-an-emergency-service-for-your-codebase
[Rubocop's official guide]: https://docs.rubocop.org/rubocop/development.html
