[Ruby] Let’s make a custom_cop that points out the shaking of the name

9 minute read

Introduction :cat2:

fanclub and fan_club are mixed in the code… I think this is a common case. I would like to unify this as the one in the team, but I do not have much memory in my head to remember such rules. I don’t want people to point me out, and I’d like to leave it to a computer that points out patiently many times. So, I made a custom_cop from Rubocop.

Source is here.

By the way, even if you do not use such a troublesome thing, it seems easier to grep to the diff with the master branch. But I wanted to write custom_cop. It’s a Sunday program, so isn’t it?

Simple specifications :cake:

If you define what you want within the range that you can understand… Is it like this? I want to define keywords in an external file, because the number of keywords will increase during application development.

List the wrong word and the word you want to correct in the YAML file, point out the wrong word and correct it.

Try making it :muscle:

Anyway, it’s not interesting if it doesn’t work, so I’ll do my best to aim for :point_down:.

If the value put in the variable is the wrong word fanclub, point it out

Check target code :dart:

Prepare a code that can be checked when running rubocop. I think the code of the actual application is also good.

target.rb


a ='fanclub'

custom_cop :cop:

I made it almost by copy and paste. Anyway, I hope that you can move like that.

lib/custom_cops/spell_inconsistency.rb


# frozen_string_literal: true

module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    WRONG_KEYWORD ='fanclub'.freeze

    def on_str(node)
      add_offense(node, message: "Use'fan_club' instead of'fanclub'.") if node.source.include?(WRONG_KEYWORD)
    end
  end
end

.rubocop.yml :wrench:

Set the custom_cop created so that you can use it with rubocop.

yaml:.rubocop.yml


require:
  -'./lib/custom_cops/spell_inconsistency'

Run :raised_hands:

If you execute it with rubocop target.rb

image.png

In addition to pointing out that variables are not used only by definition and freeze because it's a constant, `CustomCops/SpellInconsistency: Use’fan_club’ instead of’fanclub’. !!

Other words supported :zap:

I’m going to extend it to what is registered in YAML.

Point out if the value to put in the variable is the wrong word in the YAML file

spell_inconsistency.yml :wrench:

Here, register fanclub, Fanclub, and FANCLUB. In the future, I would like to put together two words like fan_club, FanClub, and FAN_CLUB.

lib/custom_cops/spell_inconsistency.yml


# Wrong: Correct
fanclub: fan_club
Fanclub: Fanclub
FANCLUB: FAN_CLUB

Check target code :dart:

target.rb


a ='fanclub'
b ='Fanclub'
c ='FANCLUB'

custom_cop :cop:

The file in which the word is registered is read with YAML.load_file, and it is checked by turning with each.

lib/custom_cops/spell_inconsistency.rb


# frozen_string_literal: true

require'yaml'

module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    MESSAGE_TEMPLATE = "Use'%s' instead of'%s'."
    SPELL_INCONSISTENCIES = YAML.load_file(Pathname(__dir__).join('spell_inconsistency.yml'))

    def on_str(node)
      SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
        add_offense(node, message: message(wrong_keyword, correct_keyword)) if node.source.include?(wrong_keyword)
      end
    end

    private

    def message(wrong_keyword, correct_keyword)
      MESSAGE_TEMPLATE% [correct_keyword, wrong_keyword]
    end
  end
end

Run :raised_hands:

If you execute it with rubocop target.rb, there are many extra messages, so if you execute it by specifying only your own custom_cop like rubocop --only CustomCops/SpellInconsistency target.rb

image.png

Oh, it looks good :thumbsup:

Assign symbol or constant

Do the same when assigning symbols or constants that look a lot like strings.

Check target code :dart:

target.rb


a ='Fanclub'
b = :fanclub
c = FANCLUB

custom_cop :cop:

Before writing, see RuboCop::AST::Traversal.

Soon there was #walk , It looks like we are looking at the type of node we are crossing over and calling the method with that name. It seems that my #on_str was also called.

lib/rubocop/ast/traversal.rb


def walk(node)
  return if node.nil?

  send(:"on_#{node.type}", node)
  nil
end

I searched everywhere in the file and found const and sym like that, so I will implement #on_sym and on_const as well. The inspection method is exactly the same, so define it with define_method.

lib/custom_cops/spell_inconsistency.rb


# frozen_string_literal: true

require'yaml'

module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    MESSAGE_TEMPLATE = "Use'%s' instead of'%s'."
    SPELL_INCONSISTENCIES = YAML.load_file(Pathname(__dir__).join('spell_inconsistency.yml'))

    NODE_TYPES = %I[str const sym].freeze
    NODE_TYPES.each do |node_type|
      define_method "on_#{node_type}" do |node|
        SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
          add_offense(node, message: message(wrong_keyword, correct_keyword)) if node.source.include?(wrong_keyword)
        end
      end
    end

    def message(wrong_keyword, correct_keyword)
      MESSAGE_TEMPLATE% [correct_keyword, wrong_keyword]
    end
  end
end

Run :raised_hands:

Run as rubocop --only CustomCops/SpellInconsistency target.rb.

image.png

Looks good :+1:

If you use the wrong word in the variable name

Now that it’s a string, a symbol, or a constant, let’s point it out if you use the wrong word in the variable name.### Check target code :dart:

target.rb


a ='Fanclub'
b = :fanclub
c = FANCLUB
fanclub ='a'

ruby-parse part 1

Speaking of variables, it would be variable and var… So, [RuboCop::AST::Traversal](https://github.com/rubocop-hq/rubocop-ast/blob/master/libIlookedat(/rubocop/ast/traversal.rb)… but there aren’t any variations, and there are many.

RuboCop Official Documentation Development Basic says that you should use ruby-parse on the command line. I understand.

image.png

It was lvasgn. Add to NODE_TYPES

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    NODE_TYPES = %I[str const sym lvasgn].freeze
    NODE_TYPES.each do |node_type|
      define_method "on_#{node_type}" do |node|
        SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
          add_offense(node, message: message(wrong_keyword, correct_keyword)) if node.source.include?(wrong_keyword)
        end
      end
    end
    (abridgement)
  end
end

Run rubocop --only CustomCops/SpellInconsistency target.rb

image.png

Although fanclub ='a' was detected… a ='FanClub was also detected…

ruby-parse (Part 2)

Let’s take a quick look at the original code and the output of ruby-parse.

(lvasgn :fanclub
  (str "a"))

The assignment to a variable is variable name = expression, right? I wonder if the expression corresponds to the character a. In this way, lvasgn is left value asign for left-hand side assignment. (I couldn’t explain it because I had to parse it in a university class a long time ago.

(Substitution on the left side: fanclub
  (String "a"))

It was caught twice in fanclub ='a', it responded to "fanclub" in str and to ““fanclub” in str in lvasgn`. It’s probably because

(lvasgn :a
  (str "fanclub"))

It should be taken just after lvasgn.

RuboCop Official Documentation Development Basic When reviewing, the methods often used in the on_~ argument node are listed. It was It looks like you should use children and use that first child.

node.type # => :send
node.children # => [s(:send, s(:send, nil, :something), :empty?), :!]
node.source # => "!something.empty?"

custom_cop :cop:

Apart from str, const, and sym, I created on_lvasgn to check for the first child.

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    NODE_TYPES = %I[str const sym].freeze
    NODE_TYPES.each do |node_type|
      define_method "on_#{node_type}" do |node|
        SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
          add_offense(node, message: message(wrong_keyword, correct_keyword)) if node.source.include?(wrong_keyword)
        end
      end
    end

    def on_lvasgn(node)
      target = node.children.first
      SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
        add_offense(node, message: message(wrong_keyword, correct_keyword)) if target.match?(/#{wrong_keyword}/)
      end
    end
    (abridgement)
  end
end

Run :raised_hands:

Run as rubocop --only CustomCops/SpellInconsistency target.rb.

image.png

Looks good :tada:

Test (RSpec) :pencil:

What we’ve done so far is checking for strings, symbols, constants and variable names. Considering the syntax of Ruby, there are method names and class names even if you can only think of a place to correspond. You will also notice things after this… Then, I was worried about writing tests because it was troublesome to execute inspection code.

settings :wrench:

The files under spec/support are require. It seems that people who are going to add it later to the application are often already set up.

spec/spec_helper.rb


RSpec.configure do |config|
  (abridgement)
  Dir["#{__dir__}/support/**/*.rb"].sort.each {|f| require f}
end

I am loading the RSpec support of rubocop and the custom_cop I added by myself.

spec/support/rubocop.rb


# frozen_string_literal: true

require'rubocop'
require'rubocop/rspec/support'
Dir["#{__dir__}/../../lib/**/*.rb"].sort.each {|f| require f}

RSpec.configure do |config|
  config.include(RuboCop::RSpec::ExpectOffense)
end

test :pencil:

What was written in the code to be checked is posted to the test, and it is also described how the output when rubocop is executed is output.

spec/lib/custom_cops/spell_inconstency_spec.rb


# frozen_string_literal: true

RSpec.describe CustomCops::SpellInconsistency do
  subject(:cop) {described_class.new}

  it'be able to detect mistakes in strings' do
    expect_offense(<<-RUBY)
      fan_club ='fanclub'
                 ^^^^^^^^^ Use'fan_club' instead of'fanclub'.
    RUBY
  end

  it'be able to detect incorrect symbols' do
    expect_offense(<<-RUBY)
      fan_club = :fanclub
                 ^^^^^^^^ Use'fan_club' instead of'fanclub'.
    RUBY
  end

  it'be able to detect incorrect constants' do
    expect_offense(<<-RUBY)
      fan_club = FANCLUB
                 ^^^^^^^ Use'FAN_CLUB' instead of'FANCLUB'.
    RUBY
  end

  it'be able to detect wrong name of variable' do
    expect_offense(<<-RUBY)
      fanclub ='fan_club'
      ^^^^^^^^^^^^^^^^^^^^ Use'fan_club' instead of'fanclub'.
    RUBY
  end
end

Run :raised_hands:

If you execute it with rspec spec/lib/custom_cops/spell_inconstency_spec.rbimage.png

Looks good :thumbsup:

Constant definition

I defined the variables, but not the constants, so I’ll do it.

Check target code :dart:

Short code for constant and method definitions.

target.rb


FANCLUB ='a'

ruby-parse

Parse with ruby-parse.

image.png

The constant definition is casgn, which is a different format than lvasgn. It was the second.

custom_cop :cop:

I have created a method to check for casgn.

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    def on_casgn(node)
      target = node.children[1]
      SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
        add_offense(node, message: message(wrong_keyword, correct_keyword)) if target.match?(/#{wrong_keyword}/)
      end
    end
    (abridgement)
  end
end

Run :raised_hands:

Run as rubocop --only CustomCops/SpellInconsistency target.rb.

image.png

Looks good :thumbsup:

test :pencil:

I will add it to the test so that I will notice it even if it breaks.

spec/lib/custom_cops/spell_inconstency_spec.rb


# frozen_string_literal: true

RSpec.describe CustomCops::SpellInconsistency do
  subject(:cop) {described_class.new}
  (abridgement)
  it'be able to detect mistakes in constant names' do
    expect_offense(<<-RUBY)
      FANCLUB ='fan_club'
      ^^^^^^^^^^^^^^^^^^^^ Use'FAN_CLUB' instead of'FANCLUB'.
    RUBY
  end
end

Brush up :sparkles:

By now, I have somehow understood the procedure.

  1. Write the code piece you want to detect (= write a test)
  2. Parse with ruby-parse
  3. Implement

Repeat this to increase the detection accuracy.

method definition

I will give it a name when defining the method, so I will look at it. Since there are many elements in one line and many types of elements, we will cut them into smaller pieces.

method name

Considering a short piece of code, def set_fanclub; hoge; end Is it like this?

Looking at ruby-parse…

image.png

The first child of def seems to be like that. It is the same as lvasgn used when assigning to a variable. As with str and const, we use define_method in the loop to group them together.

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    NODE_TYPES_ONE = %I[str const sym].freeze
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def].freeze

    NODE_TYPES_ONE.each do |node_type|
      define_method "on_#{node_type}" do |node|
        SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
          add_offense(node, message: message(wrong_keyword, correct_keyword)) if node.source.include?(wrong_keyword)
        end
      end
    end

    NODE_TYPES_FIRST_CHILD.each do |node_type|
      define_method "on_#{node_type}" do |node|
        target = node.children.first
        SPELL_INCONSISTENCIES.each do |wrong_keyword, correct_keyword|
          add_offense(node, message: message(wrong_keyword, correct_keyword)) if target.match?(/#{wrong_keyword}/)
        end
      end
    end
    (abridgement)
  end
end

arguments

The argument is arg, which has the same shape as lvasgn, so we will put it together.

image.png

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg].freeze
    (abridgement)
  end
end

Default value of #### argument

You can give default values to the arguments, but since they were the same as str, sym, and const respectively, add nothing.

image.png

keyword arguments

The keyword argument is kwarg, which is the same as lvasgn, so I will summarize it.

image.png

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg kwarg].freeze
    (abridgement)
  end
end

keyword option argument

When the default value is given to the keyword argument, the argument name is kwoptarg, which is the same as lvasgn, so it is summarized.

image.png

lib/custom_cops/spell_inconsistency.rb


(abridgement)
module CustomCops
  class SpellInconsistency <RuboCop::Cop::Cop
    (abridgement)
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg kwarg kwoptarg].freeze
    (abridgement)
  end
end

hash

I remember doing keyword arguments for method definitions. It was the same as the symbol.

image.png

Class definition, module definition

The class name and module name were const.

image.png

image.png

Try it

I’ve come this far… I feel like there are still many implementation leaks. However, since I am writing tests, I feel like I can gradually grow up based on what I noticed.

But… what I wanted to do was super easy, but it was really hard. No way, it would be a bit of parsing…However, I was able to get along with Rubocop. Also, if you want to create rules that you can’t remember with your work code, I’ll give it a try.

Refer to ##