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_class
, on_if
, on_send
. In this case, we’re going to use on_send
:
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.
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
It’s alive!
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
as
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.