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:
# 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:
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.
# config/feature_flags.yml two_factor_authentication: "Enabled 2-factor auth for all users" -fix_1234: "Check for nils on User#can_vote?" # ...
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:
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:
(+ (* 3 5) 1)
Tip: If you want to learn more about how languages (especially interpreted ones) work, check out the amazing book Crafting Interpreters by Bob Nystrom.
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:
$ 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:
(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_send. In this case, we’re going to use
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
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.
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:
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
That’s it! We can require our custom class in
.rubocop.yml and run it along with other cops:
# .rubocop.yml require: - ./lib/custom_cops/unknown_feature_flag.rb
Or run it standalone:
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
def foo if Feature.enabled?(:some_flag_we_cleaned_up) new_code else old_code end end
def foo new_code end
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.