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. The Custom/ prefix comes from the Custom 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 for rubocop --display-cop-names.
  • Include: and Exclude: 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!