はじめに
コード中にfanclub
とfan_club
が混じってしまう…よくあることだと思います。チームでこっちのほうだよね、という方に統一したいのですが、そんなルールを覚えられるほど自分の頭のメモリは多くありません…。人に指摘してもらうことをアテにするのもアレですし、何度も根気強く指摘してくれるコンピュータにおまかせしたいです。
…というわけで、Rubocopのcustom_copを作ってみました。
ちなみに、こんな面倒な手を使わずとも、masterブランチとのdiffに対して、grepしたらもっと簡単にできそうです。でも、custom_cop
を書いてみたかったんです。日曜プログラムなんだからいいでしょ?
簡単な仕様
ほしいものをわかる範囲で定義してみると…こんな感じでしょうか。
キーワードはアプリケーションの開発中にいくつも増えるので、外部ファイルに定義しておきたいです。
YAMLファイルに間違っているワードと訂正したいワードを列挙しておき、間違っているワードを見つけたら指摘し、修正します。
試しに作ってみる
何はともあれ、動かないとおもしろくないのでくらいを目指してがんばります。
変数に入れる値が間違っているワードfanclubだったら指摘する
チェック対象コード
rubocopを動かしたときに、チェックできるようなコードを用意します。実際に使っているアプリケーションのコードでもいいと思います。
a = 'fanclub'
custom_cop
ほぼコピペで作りました。とにかくそれっぽく動いてもらえればいいと思います。
# 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
できたcustom_copをrubocopで使ってもらえるように設定します。
require:
- './lib/custom_cops/spell_inconsistency'
実行
rubocop target.rb
で実行すると…。
変数を定義だけで使ってないぞ
とか、定数なんだからfreezeしたまえ
などの指摘の他に…CustomCops/SpellInconsistency: Use 'fan_club' instead of 'fanclub'.
が出てくれました!
他のワードも対応
YAMLに登録されているものに拡張しようと思います。
変数に入れる値がYAMLファイルに間違っているワードだったら指摘する
spell_inconsistency.yml
ここでは fanclub
、Fanclub
、FANCLUB
を登録します。
将来的にはそれぞれ、fan_club
、FanClub
、FAN_CLUB
の2単語っぽく記述するほうにまとめたいという気持ちです。
# Wrong: Correct
fanclub: fan_club
Fanclub: Fanclub
FANCLUB: FAN_CLUB
チェック対象コード
a = 'fanclub'
b = 'Fanclub'
c = 'FANCLUB'
custom_cop
YAML.load_file
でワードの登録されたファイルを読み込んで、each
で回してチェックしています。
# 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
実行
rubocop target.rb
で実行すると余計なメッセージが多いので、rubocop --only CustomCops/SpellInconsistency target.rb
のように自作のcustom_copだけ指定して実行すると…。
ああ、よさそう
シンボルや定数を代入
文字列にパッと見似ていそうな、シンボルや定数を代入したときにも同じようにしてみます。
チェック対象コード
a = 'Fanclub'
b = :fanclub
c = FANCLUB
custom_cop
書く前にRuboCop::AST::Traversal
を見てます。
すぐソコにあった#walk
見ると、渡ってきたノードのタイプを見て、その名前のメソッドを呼んでいるようです。
それで自分の#on_str
も呼ばれたようです。
def walk(node)
return if node.nil?
send(:"on_#{node.type}", node)
nil
end
ファイル内をくまなく探して、それっぽいconst
とsym
があったので、#on_sym
とon_const
も実装することにします。検査方法などは全く同じなので、define_method
で定義します。
# 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
実行
rubocop --only CustomCops/SpellInconsistency target.rb
として実行すると…。
よさそう
変数名に間違っているワードを使ったら指摘
文字列、シンボル、定数とできたので、今度は変数名に間違っているワードを使ったら指摘してもらうようにしましょう。
チェック対象コード
a = 'Fanclub'
b = :fanclub
c = FANCLUB
fanclub = 'a'
ruby-parse その1
変数といえばvariable
でvar
だろう…ということで、RuboCop::AST::Traversal
を見てみたわけですが…そのものズバリはなく、いっぱいあります…。
RuboCopの公式ドキュメントのDevelopmentのBasicを見てみると、コマンドラインでruby-parse
を使いなさいというのがわかりました。
lvasgn
でした。
NODE_TYPES
に追加して…
(省略)
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
を実行すると…。
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
str
、const
、sym
とは別に、on_lvasgn
を作って、最初の子に対して検査するようにしました。
(省略)
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
実行
rubocop --only CustomCops/SpellInconsistency target.rb
として実行すると…。
よさそう
テスト(RSpec)
ここまでやってきてできたことは…文字列、シンボル、定数と変数名のチェックです。
Rubyの構文を考えると…まだ対応すべき箇所が思いつくだけでもメソッド名やクラス名があります。このあとも気がつくことが出てくるでしょう…。
そうすると、いちいち検査用のコードを実行するのも面倒なので、テストを書きたくなってきました。
設定
spec/support
以下のファイルをrequire
してます。
アプリケーションにあとから入れようとしているひとはすでに設定されていることも多そうです。
RSpec.configure do |config|
(省略)
Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f }
end
rubocopのRSpecのサポートを読み込んでいるのと、自分で追加したcustom_copを読み込んでいます。
# 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
テスト
チェック対象コードに書いてあったものをテストに転記して、rubocopを実行したときの出力がどう出るかをあわせて記述しています。
# 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
実行
rspec spec/lib/custom_cops/spell_inconstency_spec.rb
で実行すると…。
よさそう
定数定義
変数の定義はやりましたが、定数定義はまだでしたので、やっていきます。
チェック対象コード
定数定義とメソッド定義の短めのコードです。
FANCLUB = 'a'
ruby-parse
ruby-parse
で解析します。
定数定義はcasgn
で、lvasgn
とは違う形式です。2番目でした。
custom_cop
casgn
用チェックするメソッドを作りました。
(省略)
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
実行
rubocop --only CustomCops/SpellInconsistency target.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
ブラッシュアップ
ここまできて、なんとなく手順がわかってきました。
- 検出させたいコード片を書く(=テストを書く)
- ruby-parseで解析する
- 実装する
これを繰り返して、検出精度を上げていきます。
メソッド定義
メソッドを定義するときにも名前をつけるので、見ていきます。
一行の要素数が多いことと、要素の種類が多いので、細かく刻んでいきます。
メソッド名
短いコード片を考えると def set_fanclub; hoge; end
こんな感じでしょうか。
ruby-parseで見てみると…。
def
のひとつめのchild
がそのようです。変数への代入のときに使ったlvasgn
と同じようです。
str
やconst
と同じように、ループでdefine_method
を使ってまとめます。
(省略)
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
と同じ形なので、まとめます。
(省略)
module CustomCops
class SpellInconsistency < RuboCop::Cop::Cop
(省略)
NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg].freeze
(省略)
end
end
引数のデフォルト値
引数にデフォルト値を与えられますが、これはそれぞれstr
、sym
、const
と同じだったので、何も足しません。
キーワード引数
キーワード引数はkwarg
で、lvasgn
と同じ形なので、まとめます。
(省略)
module CustomCops
class SpellInconsistency < RuboCop::Cop::Cop
(省略)
NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg kwarg].freeze
(省略)
end
end
キーワードオプション引数
キーワード引数にデフォルト値を与えたときの、引数名はkwoptarg
で、lvasgn
と同じ形なので、まとめます。
(省略)
module CustomCops
class SpellInconsistency < RuboCop::Cop::Cop
(省略)
NODE_TYPES_FIRST_CHILD = %I[lvasgn def arg kwarg kwoptarg].freeze
(省略)
end
end
ハッシュ
メソッド定義のキーワード引数をやっていて思い出しました。
シンボルと同じもので対応されていました。
クラス定義、モジュール定義
クラス名やモジュール名はconst
でした。
やってみて
ここまでやってきて…まだいっぱい実装漏れがありそうな気がします。
とはいえ、テストも書いているので、気づいたベースで徐々に育てていけそうな気がしています。
しかし…やりたいことは超簡単でしたが、めちゃくちゃ大変でした。まさか、構文解析をかじることになるとは…。
とはいえ、Rubocopと仲良くなれました。また仕事のコードで覚えきれないルールを作りたくなったら、チャレンジしてみようかと思います。