2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに :cat2:

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

ソースはこちら

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

簡単な仕様 :cake:

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

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

試しに作ってみる:muscle:

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

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

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

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

target.rb
a = 'fanclub'

custom_cop :cop:

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

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で使ってもらえるように設定します。

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

実行 :raised_hands:

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

image.png

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

他のワードも対応 :zap:

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

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

spell_inconsistency.yml :wrench:

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

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

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

target.rb
a = 'fanclub'
b = 'Fanclub'
c = 'FANCLUB'

custom_cop :cop:

YAML.load_fileでワードの登録されたファイルを読み込んで、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

実行 :raised_hands:

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

image.png

ああ、よさそう :thumbsup:

シンボルや定数を代入

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

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

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

custom_cop :cop:

書く前にRuboCop::AST::Traversalを見てます。

すぐソコにあった#walk見ると、渡ってきたノードのタイプを見て、その名前のメソッドを呼んでいるようです。
それで自分の#on_strも呼ばれたようです。

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

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

ファイル内をくまなく探して、それっぽいconstsymがあったので、#on_symon_constも実装することにします。検査方法などは全く同じなので、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

実行 :raised_hands:

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

image.png

よさそう :thumbsup:

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

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

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

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

ruby-parse その1

変数といえばvariablevarだろう…ということで、RuboCop::AST::Traversalを見てみたわけですが…そのものズバリはなく、いっぱいあります…。

RuboCopの公式ドキュメントのDevelopmentのBasicを見てみると、コマンドラインでruby-parseを使いなさいというのがわかりました。

image.png

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

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

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を見直すと、on_〜の引数nodeでよく使うメソッドが載っていました。childrenを使って、その最初の子を使えばよさそうです。

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

custom_cop :cop:

strconstsymとは別に、on_lvasgnを作って、最初の子に対して検査するようにしました。

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

よさそう :tada:

テスト(RSpec) :pencil:

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

設定 :wrench:

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

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

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

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を実行したときの出力がどう出るかをあわせて記述しています。

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

よさそう :thumbsup:

定数定義

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

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

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

target.rb
FANCLUB = 'a'

ruby-parse

ruby-parseで解析します。

image.png

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

custom_cop :cop:

casgn用チェックするメソッドを作りました。

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

よさそう :thumbsup:

テスト :pencil:

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

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

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

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

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

引数のデフォルト値

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

image.png

キーワード引数

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

image.png

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

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

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

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

image.png

image.png

やってみて

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

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

参照

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?