This article is the detailed text version of this video:
I'll explain how to create a custom Ruby lint rule with RuboCop (a.k.a. "custom cop") from scratch. In this tutorial, I'll write an example cop to sort Hash literal elements by their keys.
Start from template
The cop to be created in this tutorial will be implemented in the form of a RuboCop plugin. Because RuboCop does not yet have a plug-in mechanism, we have to write a lot of additional code to make it work as a plugin.
This will be a bit hard work, so I prepared a template that includes the necessary code for scaffolding that.
Overview
To add a new cop, you need to do the following steps:
- Add test
- spec/rubocop/cop/my_extension/hash_literal_order_spec.rb
- Add cop class
- lib/rubocop/cop/my_extension/hash_literal_order.rb
- Load cop class
- lib/my_extension.rb
- Add default config
- default.yml
RuboCop itself and plugins like rubocop-rspec and rubocop-rails are also implemented in the same way.
Add test
Basically, cop tests are written in RSpec. You don't need to have written RSpec before. Just copy and paste the existing sample code and it will work.
All you need to know is that these 3 methods are officialy provided by RuboCop.
expect_offense(code_with_message)
expect_no_offenses(code)
expect_correction(code)
MyExtension/HashLiteralOrder
is the cop name that we are writing.
Every cop has a corresponding class, and they are defined under ::RuboCop::Cop
namespace.
This is apparently no exception, even for 3rd party plug-ins.
So the minimum example of our test code will be like this:
# spec/rubocop/cop/my_extension/hash_literal_order_spec.rb
RSpec.describe RuboCop::Cop::MyExtension::HashLiteralOrder, :config do
it 'autocorrects offense' do
expect_offense(<<~TEXT)
{ b: 1, c: 1, a: 1 }
^^^^^^^^^^^^^^^^^^^^ Sort Hash literal entries by key.
TEXT
expect_correction(<<~RUBY)
{ a: 1, b: 1, c: 1 }
RUBY
end
end
expect_offense
takes as its argument not only the Ruby code, but also a string with the offence message. This is a bit tricky, but it actually useful.
Add cop class
Once you have written the tests, it's time to write the implementation.
I'll write the near-complete implementation of the cop.
I know there are a lot of parts you don't know, but don't panic.
From here on, I'll explain step by step in comments in the code,
not in plain text, so please read along with the code.
# lib/rubocop/cop/my_extension/hash_literal_order.rb
module RuboCop
module Cop
module MyExtension
# By convention, we write fairly detailed documentation for each cop
# using YARD comments. Even official RuboCop cops do so, and the official
# documentation site is automatically generated from these comments there.
# e.g. https://docs.rubocop.org/rubocop/cops_layout.html
#
#
# Sort Hash literal entries by key.
#
# @example
#
# # bad
# {
# b: 1,
# a: 1,
# c: 1
# }
#
# # good
# {
# a: 1,
# b: 1,
# c: 1
# }
#
#
# As I mentioned previously, if you want to create `MyExtenison/HashLiteralOrder` cop,
# the class name should be `::RuboCop::Cop::MyExtension::HashLiteralOrder`.
# All cop classes must inherit from RuboCop::Cop::Base,
# so here we wrote `< Base` as well.
class HashLiteralOrder < Base
# To support `--autocorrect` in this cop,
# we need to extend this module.
extend AutoCorrector
# If you define `MSG` constant in cop class,
# RuboCop will use it as an offense message for this cop.
MSG = 'Sort Hash literal entries by key.'
# RuboCop provides `def_node_matcher` to easily define pattern-matching method,
# which takes a Symbol method name and a String AST pattern,
# In this case, it defines `#hash_literal_order` method,
# which takes an AST node as argument and returns a boolean value.
#
# For example, this method returns true for `{ a: 1, b: 2 }`,
# and returns false for `[1, 2]` and `{ a => b }`.
#
# @!method hash_literal?(node)
# @param [RuboCop::AST::HashNode] node
# @return [Boolean]
def_node_matcher :hash_literal?, <<~PATTERN
(hash
(pair
{sym | str}
_
)+
)
PATTERN
# This is a callback method that is called when RuboCop detects
# a Hash node. We use this mechanism to implement the registration
# and autocorrection of offenses when we find a code pattern that
# we want to detect.
#
# The key method is `add_offense`.
# By calling this, you can tell RuboCop that you've found an offense.
#
# In this case, if the keys of the target Hash node are all Symbols
# or Strings and not sorted by name, we consider it an offense,
# then replace the entire Hash node with a new code, which is
# specified by the Ruby code as a String.
#
# @param [RuboCop::AST::HashNode] node
def on_hash(node)
return unless hash_literal?(node)
return if sorted?(node)
add_offense(node) do |corrector|
corrector.replace(
node,
autocorrect(node)
)
end
end
private
# ... some internal implementation ...
# ... such as `#sorted?(node)`,
# ... `#autocorrect(node)`, and so on ...
end
end
end
end
The details are not described due to space limitations, so please read the original source code here:
AST pattern matching
The syntax of this pattern match has a rather strange style and may be difficult to understand at first sight. There is an easy-to-understand explanation at Node Pattern :: RuboCop Docs, so it would be better to write it by hand while looking at this at first.
(hash
(pair
{sym | str}
_
)+
)
There is a useful CLI tool in the parser gem called ruby-parse
, which shows the parsed results as ASTs, and I often check it when writing Cop like this:
$ gem install parser
$ echo '{ a: 1, b: 1 }' > example.rb
$ ruby-parse example.rb
(hash
(pair
(sym :a)
(int 1))
(pair
(sym :b)
(int 1)))
Load cop class
Don't forget to load the cop class file.
Sometimes I forget this and get confused.
# lib/my_extension.rb
require 'rubocop/cop/my_extension/hash_literal_order'
Add default config
Finally, don't forget to add default config for the cop you added.
Each cop can have its own configuration values, and it is common practice for RuboCop plug-ins to include their default settings in default.yml
.
# default.yml
MyExtension/HashLiteralOrder:
Description: |
Sort Hash literal entries by key.
Enabled: true
VersionAdded: '0.1'
Not the cop is complete! This cop works as follows.
$ bundle exec rubocop
Inspecting 1 files
C
Offenses:
example.rb:3:1: C: [Correctable] MyExtension/HashLiteralOrder: Sort Hash literal entries by key.
{ b: 2, a: 1, c: 3 }
^^^^^^^^^^^^^^^^^^^^
1 files inspected, 1 offense detected, 1 offense autocorrectable
The same cop we created here is included in my gem called sevencop. You can try this cop in the following repository.
Wrapping up
I explained how to create a custom cop from scratch in this tutorial.
The finished version of this cop's source is available at GitHub:
As mentioned at the beginning of this article, I'm also providing a live coding video for this tutorial. If you have any questions, don't hesitate to ask, either in the comments of this article or the video.
If you want to know more about how to create custom cops, I recommend that you read these pages:
- https://github.com/rubocop/rubocop
- https://github.com/rubocop/rubocop-ast
- https://github.com/whitequark/parser
I have posted other tutorials if you would like to see them.
Thank you for reading so far. If you have any questions, please ask anything in the comments.
Good coding!
Top comments (0)