名前の揺れを指摘するcustom_copを作ろう

## はじめに :cat2: 
コード中に`fanclub`と`fan_club`が混じってしまう…よくあることだと思います。チームでこっちのほうだよね、という方に統一したいのですが、そんなルールを覚えられるほど自分の頭のメモリは多くありません…。人に指摘してもらうことをアテにするのもアレですし、何度も根気強く指摘してくれるコンピュータにおまかせしたいです。
…というわけで、[Rubocop](https://github.com/rubocop-hq/rubocop)のcustom_copを作ってみました。

[ソースはこちら](https://github.com/ken1flan/spell_inconsistency_cop_sample)。

ちなみに、こんな面倒な手を使わずとも、masterブランチとのdiffに対して、grepしたらもっと簡単にできそうです。でも、`custom_cop`を書いてみたかったんです。日曜プログラムなんだからいいでしょ？

## 簡単な仕様 :cake:
ほしいものをわかる範囲で定義してみると…こんな感じでしょうか。
キーワードはアプリケーションの開発中にいくつも増えるので、外部ファイルに定義しておきたいです。

```
YAMLファイルに間違っているワードと訂正したいワードを列挙しておき、間違っているワードを見つけたら指摘し、修正します。
```

## 試しに作ってみる:muscle:

何はともあれ、動かないとおもしろくないので:point_down:くらいを目指してがんばります。

```
変数に入れる値が間違っているワードfanclubだったら指摘する
```

### チェック対象コード :dart: 

rubocopを動かしたときに、チェックできるようなコードを用意します。実際に使っているアプリケーションのコードでもいいと思います。

```ruby:target.rb
a = 'fanclub'
```

### custom_cop :cop:

ほぼコピペで作りました。とにかくそれっぽく動いてもらえればいいと思います。

```ruby: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:

できたcustom_copをrubocopで使ってもらえるように設定します。

```yaml:.rubocop.yml
require:
  - './lib/custom_cops/spell_inconsistency'
```

### 実行 :raised_hands:

`rubocop target.rb`で実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/7312f92c-cd67-d83b-e7aa-f437ae9c61df.png)


`変数を定義だけで使ってないぞ`とか、`定数なんだからfreezeしたまえ`などの指摘の他に…`CustomCops/SpellInconsistency: Use 'fan_club' instead of 'fanclub'.`が出てくれました！

## 他のワードも対応 :zap: 

YAMLに登録されているものに拡張しようと思います。

```
変数に入れる値がYAMLファイルに間違っているワードだったら指摘する
```

### spell_inconsistency.yml :wrench:

ここでは `fanclub`、`Fanclub`、`FANCLUB` を登録します。
将来的にはそれぞれ、`fan_club`、`FanClub`、`FAN_CLUB`の2単語っぽく記述するほうにまとめたいという気持ちです。

```yaml:lib/custom_cops/spell_inconsistency.yml
# Wrong: Correct
fanclub: fan_club
Fanclub: Fanclub
FANCLUB: FAN_CLUB
```

### チェック対象コード :dart: 

```ruby:target.rb
a = 'fanclub'
b = 'Fanclub'
c = 'FANCLUB'
```

### custom_cop :cop:

`YAML.load_file`でワードの登録されたファイルを読み込んで、`each`で回してチェックしています。

```ruby: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
```

### 実行 :raised_hands:

`rubocop target.rb`で実行すると余計なメッセージが多いので、`rubocop --only CustomCops/SpellInconsistency target.rb`のように自作のcustom_copだけ指定して実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/dc373801-f9db-2156-b076-0a583f72e03c.png)


ああ、よさそう :thumbsup: 

## シンボルや定数を代入

文字列にパッと見似ていそうな、シンボルや定数を代入したときにも同じようにしてみます。

### チェック対象コード :dart: 

```ruby:target.rb
a = 'Fanclub'
b = :fanclub
c = FANCLUB
```

### custom_cop :cop:

書く前に[`RuboCop::AST::Traversal`](https://github.com/rubocop-hq/rubocop-ast/blob/master/lib/rubocop/ast/traversal.rb)を見てます。

すぐソコにあった[`#walk`]((https://github.com/rubocop-hq/rubocop-ast/blob/master/lib/rubocop/ast/traversal.rb#L11-L16))見ると、渡ってきたノードのタイプを見て、その名前のメソッドを呼んでいるようです。
それで自分の`#on_str`も呼ばれたようです。

```ruby:lib/rubocop/ast/traversal.rb
def walk(node)
  return if node.nil?

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

ファイル内をくまなく探して、それっぽい`const`と`sym`があったので、`#on_sym`と`on_const`も実装することにします。検査方法などは全く同じなので、`define_method`で定義します。

```ruby: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
```

### 実行 :raised_hands:

`rubocop --only CustomCops/SpellInconsistency target.rb`として実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/43c9e080-3362-c8cf-5a96-80a9333e9a63.png)

よさそう :+1:


## 変数名に間違っているワードを使ったら指摘

文字列、シンボル、定数とできたので、今度は変数名に間違っているワードを使ったら指摘してもらうようにしましょう。

### チェック対象コード :dart: 

```ruby:target.rb
a = 'Fanclub'
b = :fanclub
c = FANCLUB
fanclub = 'a'
```

### ruby-parse その1

変数といえば`variable`で`var`だろう…ということで、[`RuboCop::AST::Traversal`](https://github.com/rubocop-hq/rubocop-ast/blob/master/lib/rubocop/ast/traversal.rb)を見てみたわけですが…そのものズバリはなく、いっぱいあります…。

[RuboCopの公式ドキュメントのDevelopmentのBasic](https://docs.rubocop.org/rubocop/0.87/development.html#basics)を見てみると、コマンドラインで`ruby-parse`を使いなさいというのがわかりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/0c54dd10-af5e-685a-ed7f-11c4182e8327.png)

`lvasgn`でした。
`NODE_TYPES`に追加して…

```ruby:lib/custom_cops/spell_inconsistency.rb
(省略)
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    (省略)
    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
    (省略)
  end
end
```
`rubocop --only CustomCops/SpellInconsistency target.rb`を実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/89c809e1-9064-e258-f87e-b989d0bb2271.png)

`fanclub = 'a'`は検出されましたが…`a = 'FanClub`も検出されちゃってます…。

## ruby-parse（その2）

元のコードとruby-parseの出力をしげしげ眺めてみます。

```
(lvasgn :fanclub
  (str "a"))
```

変数への代入は`変数名 = 式`ですよね…。その式にあたるのが文字列の`a`なのかな。
こうしてみていたら、`lvasgn`は左辺代入の`left value asign`かな。
（遠い昔に大学の授業で構文解析があったので、説明できないけどなんとなくわかるレベルで解説できず…すみません。）

```
(左辺代入 :fanclub
  (文字列 "a")) 
```

`fanclub = 'a'`で2回ひっかかっていたのは、`str`の`"fanclub"`に反応したのと、`lvasgn`の中にある`str`の`"fanclub"`に反応したからでしょう。

```
(lvasgn :a
  (str "fanclub"))
```

`lvasgn`の直後だけ取ればよさそう。

[RuboCopの公式ドキュメントのDevelopmentのBasic](https://docs.rubocop.org/rubocop/0.87/development.html#basics)を見直すと、`on_〜`の引数`node`でよく使うメソッドが載っていました。`children`を使って、その最初の子を使えばよさそうです。

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

### custom_cop :cop:
`str`、`const`、`sym`とは別に、`on_lvasgn`を作って、最初の子に対して検査するようにしました。

```ruby:lib/custom_cops/spell_inconsistency.rb
（省略）
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    （省略）
    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
    （省略）
  end
end
```

### 実行 :raised_hands:

`rubocop --only CustomCops/SpellInconsistency target.rb`として実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/1cba4097-95a5-f339-fdcc-2f7e3e279739.png)

よさそう :tada: 

## テスト（RSpec） :pencil:

ここまでやってきてできたことは…文字列、シンボル、定数と変数名のチェックです。
Rubyの構文を考えると…まだ対応すべき箇所が思いつくだけでもメソッド名やクラス名があります。このあとも気がつくことが出てくるでしょう…。
そうすると、いちいち検査用のコードを実行するのも面倒なので、テストを書きたくなってきました。

### 設定 :wrench: 

`spec/support`以下のファイルを`require`してます。
アプリケーションにあとから入れようとしているひとはすでに設定されていることも多そうです。

```ruby:spec/spec_helper.rb
RSpec.configure do |config|
  （省略）
  Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f }
end
```

rubocopのRSpecのサポートを読み込んでいるのと、自分で追加したcustom_copを読み込んでいます。

```ruby: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
```

### テスト :pencil: 

チェック対象コードに書いてあったものをテストに転記して、rubocopを実行したときの出力がどう出るかをあわせて記述しています。

```ruby:spec/lib/custom_cops/spell_inconstency_spec.rb
# frozen_string_literal: true

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

  it '文字列のまちがいを検知できること' do
    expect_offense(<<-RUBY)
      fan_club = 'fanclub'
                 ^^^^^^^^^ Use 'fan_club' instead of 'fanclub'.
    RUBY
  end

  it 'シンボルのまちがいを検知できること' do
    expect_offense(<<-RUBY)
      fan_club = :fanclub
                 ^^^^^^^^ Use 'fan_club' instead of 'fanclub'.
    RUBY
  end

  it '定数のまちがいを検知できること' do
    expect_offense(<<-RUBY)
      fan_club = FANCLUB
                 ^^^^^^^ Use 'FAN_CLUB' instead of 'FANCLUB'.
    RUBY
  end

  it '変数名のまちがいを検知できること' do
    expect_offense(<<-RUBY)
      fanclub = 'fan_club'
      ^^^^^^^^^^^^^^^^^^^^ Use 'fan_club' instead of 'fanclub'.
    RUBY
  end
end
```

### 実行 :raised_hands:

`rspec spec/lib/custom_cops/spell_inconstency_spec.rb`で実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/c6c7795a-de00-5ca1-ddd7-5c9468d14be5.png)

よさそう :thumbsup: 

## 定数定義

変数の定義はやりましたが、定数定義はまだでしたので、やっていきます。

### チェック対象コード :dart: 

定数定義とメソッド定義の短めのコードです。

```ruby:target.rb
FANCLUB = 'a'
```

### ruby-parse

`ruby-parse`で解析します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/6199af8e-1647-11c1-052b-cbd813b1e74c.png)

定数定義は`casgn`で、`lvasgn`とは違う形式です。2番目でした。

### custom_cop :cop:
`casgn`用チェックするメソッドを作りました。


```ruby:lib/custom_cops/spell_inconsistency.rb
（省略）
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    （省略）
    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
    （省略）
  end
end
```

### 実行 :raised_hands:

`rubocop --only CustomCops/SpellInconsistency target.rb`として実行すると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/5ca7aa44-0f41-6011-3188-80d0c309a75b.png)

よさそう :thumbsup: 

### テスト :pencil: 

テストに追記して、壊れても気づけるようにします。

```ruby:spec/lib/custom_cops/spell_inconstency_spec.rb
# frozen_string_literal: true

RSpec.describe CustomCops::SpellInconsistency do
  subject(:cop) { described_class.new }
  （省略）
  it '定数名のまちがいを検知できること' do
    expect_offense(<<-RUBY)
      FANCLUB = 'fan_club'
      ^^^^^^^^^^^^^^^^^^^^ Use 'FAN_CLUB' instead of 'FANCLUB'.
    RUBY
  end
end
```

## ブラッシュアップ :sparkles: 

ここまできて、なんとなく手順がわかってきました。

1. 検出させたいコード片を書く（=テストを書く）
2. ruby-parseで解析する
3. 実装する

これを繰り返して、検出精度を上げていきます。

### メソッド定義

メソッドを定義するときにも名前をつけるので、見ていきます。
一行の要素数が多いことと、要素の種類が多いので、細かく刻んでいきます。

#### メソッド名

短いコード片を考えると `def set_fanclub; hoge; end` こんな感じでしょうか。

ruby-parseで見てみると…。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/de519148-eaa2-f571-6662-44b9542fec68.png)

`def`のひとつめの`child`がそのようです。変数への代入のときに使った`lvasgn`と同じようです。
`str`や`const`と同じように、ループで`define_method`を使ってまとめます。

```ruby:lib/custom_cops/spell_inconsistency.rb
（省略）
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    （省略）
    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
    （省略）
  end
end
```

#### 引数

引数は`arg`で、`lvasgn`と同じ形なので、まとめます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/f1aba9d6-ee38-8707-fe9c-3e62839b2132.png)

```ruby:lib/custom_cops/spell_inconsistency.rb
（省略）
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    （省略）
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg].freeze
    （省略）
  end
end
```

#### 引数のデフォルト値

引数にデフォルト値を与えられますが、これはそれぞれ`str`、`sym`、`const`と同じだったので、何も足しません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/c7539c5f-7bb9-3cf1-7091-d1763c5a07c1.png)

#### キーワード引数

キーワード引数は`kwarg`で、`lvasgn`と同じ形なので、まとめます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/a8e054dc-b04d-459b-998c-8d905e24d46d.png)

```ruby:lib/custom_cops/spell_inconsistency.rb
（省略）
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    （省略）
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg kwarg].freeze
    （省略）
  end
end
```

#### キーワードオプション引数

キーワード引数にデフォルト値を与えたときの、引数名は`kwoptarg`で、`lvasgn`と同じ形なので、まとめます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/d1ec804c-2bee-c67b-a642-12f8c5f31117.png)


```ruby:lib/custom_cops/spell_inconsistency.rb
（省略）
module CustomCops
  class SpellInconsistency < RuboCop::Cop::Cop
    （省略）
    NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg kwarg kwoptarg].freeze
    （省略）
  end
end
```


### ハッシュ

メソッド定義のキーワード引数をやっていて思い出しました。
シンボルと同じもので対応されていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/8e88467b-23d2-c344-1b19-08050a066c86.png)

### クラス定義、モジュール定義

クラス名やモジュール名は`const`でした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/90cb28b8-7166-5516-7f50-8f4ba7531ff3.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14380/6e22fc1b-c0c2-2ee9-160f-809e99118861.png)

## やってみて
ここまでやってきて…まだいっぱい実装漏れがありそうな気がします。
とはいえ、テストも書いているので、気づいたベースで徐々に育てていけそうな気がしています。

しかし…やりたいことは超簡単でしたが、めちゃくちゃ大変でした。まさか、構文解析をかじることになるとは…。
とはいえ、[Rubocop](https://github.com/rubocop-hq/rubocop)と仲良くなれました。また仕事のコードで覚えきれないルールを作りたくなったら、チャレンジしてみようかと思います。

## 参照
- [RuboCop Official Docs](https://docs.rubocop.org)
  - [Development](https://docs.rubocop.org/rubocop/0.87/development.html)
- [RuboCopの新しいルールを追加する方法（Custom Copの作り方）](https://sinsoku.hatenablog.com/entry/2018/04/24/022911)