How to create a custom robocop rule to improve code quality?
If you're anything like me, you appreciate clean, consistent, and maintainable code. RuboCop is an indispensable tool in the Ruby ecosystem for enforcing coding standards and catching potential issues. While its default set of cops is extensive, there are times when your project has unique conventions or specific patterns you want to enforce or discourage. That's where custom RuboCop rules come into play!
Understanding the basics of a Rubocop cop
Before we dive into writing our own, let's quickly recap what a cop
is in RuboCop's world. Essentially, a cop
is a class responsible for inspecting a specific aspect of your code. It might check for style violations, potential bugs, or complex issues. RuboCop processes your code, builds an Abstract Syntax Tree (AST), and then lets various cops inspect this tree or the raw source code. When a cop finds a violation of its rule, it reports an "offense."
Crafting your first custom cop: No hardcoded phone numbers
Let's walk through an example of a custom cop. Imagine we want to prevent developers from hardcoding phone numbers directly into the source code, encouraging them to use configuration files or environment variables instead. This is a good practice for security and maintainability. Here's the code for our custom cop, which we might save as lib/cops/no_hardcoded_phone_numbers.rb
:
# lib/cops/no_hardcoded_phone_numbers.rb
module RuboCop
module Cop
module Custom
class NoHardcodedPhoneNumbers < RuboCop::Cop::Base
MSG = "Avoid hardcoding phone numbers. Use configuration or environment variables instead."
PHONE_NUMBER_REGEX = /((\+44)|(0)) ?\d{4} ?\d{6}/ # UK style, for example 447111111111
def on_str(node)
return unless node.str_content.match?(PHONE_NUMBER_REGEX)
return if allowed_context?(node)
add_offense(node)
end
def on_dstr(node)
# Also check dynamic string literals
node.each_child_node(:str) { |str_node| on_str(str_node) }
end
private
def allowed_context?(node)
# Skip test data, documentation markers, etc.
parent = node.parent
return true if in_comment?(node)
return true if label_or_key_for_phone_field?(parent, node)
return true if in_test_file?
false
end
def in_comment?(node)
processed_source.comments.any? { |comment| comment.loc.expression.contains?(node.loc.expression) }
end
def label_or_key_for_phone_field?(parent, _node)
return false unless parent&.pair_type?
# Detect cases like `phone: "123-456-7890"` or `"phone" => "0912345678"`
key_node = parent.children.first
key_text = key_node.str_content.to_s.downcase if key_node.respond_to?(:str_content)
key_text&.include?('phone')
end
def in_test_file?
processed_source.file_path&.match?(%r{/(spec|test)/})
end
end
end
end
end
For more complex, structure-aware cops, you'd typically use RuboCop's AST (Abstract Syntax Tree) traversal methods. For example, if you wanted to check only string literals, you might use the on_str
hook:
# Example of an AST-based approach (conceptual)
# def on_str(node)
# # node.source contains the string's content
# if node.source.match?(PHONE_NUMBER_REGEX)
# add_offense(node.loc.expression, message: MSG)
# end
# end
This AST-based approach is generally more robust as it understands the code's structure.
Integrating your custom cop
Now that we have our cop, we need to tell RuboCop about it. This is done in your project's .rubocop.yml
file:
# .rubocop.yml
# First, tell RuboCop where to find your custom cop files.
# The path should be relative to your project root.
require:
- ./lib/cops/no_hardcoded_phone_numbers.rb # Or your custom cops directory
# Then, configure your custom cop just like any other cop.
# The namespace 'Custom' is derived from our module structure.
Custom/NoHardcodedPhoneNumbers:
Description: 'Checks for hardcoded phone numbers in the source code. Encourages using configuration or environment variables.'
Enabled: true
# You can specify which files this cop should inspect.
Include:
- '**/*.rb' # Apply to all Ruby files
# - 'app/lib/**/*.rb' # Or be more specific
Exclude:
- 'spec/**/*' # Example: exclude spec files
In this configuration:
require:
tells RuboCop to load our custom cop file. If you have multiple custom cops in a directory, you might use a helper to require all files in that directory.Custom/NoHardcodedPhoneNumbers:
is how we refer to our cop. TheCustom/
prefix comes from theCustom
module we defined.Enabled: true
activates the cop.Description:
provides a human-readable explanation of what the cop does. This is good for documentation and forrubocop --display-cop-names
.Include:
andExclude:
allow you to control which files this cop applies to.
Seeing it in action
Once configured, if RuboCop encounters a hardcoded phone number in a file it inspects, you'll see output similar to this:
Offenses:
app/lib/notification_service.rb:15:1: C: Custom/NoHardcodedPhoneNumbers: Avoid hardcoding phone numbers. Use configuration or environment variables instead.
params[:user][:number] == '01234567890'
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The C:
indicates a "Convention" violation (by default, custom cops are often treated as convention, but this can be configured). The message and the location point directly to the offending code.
Summary
Writing custom RuboCop rules is a powerful way to enforce project-specific coding standards and catch domain-specific issues in your Ruby applications. By inheriting from RuboCop::Cop::Base
, defining violation messages, using hooks like on_new_investigation
or AST node visitors (e.g., on_str
), and registering offenses with add_offense
, you can create tailored linting rules. Integrating these rules via .rubocop.yml
allows your entire team to benefit from automated checks, leading to higher code quality and consistency. Don't be afraid to experiment and build cops that make your development life easier!
Happy copping!