2021年11月15日
プログラミング
CustomCopで命名規則を作ってみた
はじめに
現在palanでは開発規則を作っています。その中で命名規則を考えましたが、コードレビューでは見落としてしまう可能性があるためRuboCopのCustomCopを使って、RuboCopに独自ルールを追加してみました。公開されているブログ記事も少なかったため今回記事にすることにしました。
環境
- Ruby: 2.7.4
- Rails: 6.1.4
- RuboCop: 1.17.0
RuboCop について
RubocopとはRubyのコードがコーディング規約に沿っているかを確認できる「静的コード解析ツール」の1つです。
parser
RuboCopはコード解析にparser
というライブラリを使っています。これはコードの抽象構文木(AST)表現を作成するライブラリです。parser
にはコードの抽象構文木(AST)表現を作成するために実行可能形式のコマンドruby-parse
が用意されていて、以下のようにして試すことができます。
$ ruby-parse -e '1' # => (int 1)
$ ruby-parse -e 'name = "John"' # => (lvasgn :name (str "John"))
括弧で囲まれた各式はASTのノードを表しています。最初の要素はノードの種類で、末尾にはコードを表現するのに必要なすべての情報を持つ子要素が含まれています。 (ノードの種類 値1 ...)
1
は AST ではただ一つのノード (int 1)
で表現されることを表しています。
name = "John"
は AST では(lvasgn :name)
の子要素に(str "John")
で表現されることを表しています。
ノードの種類は文字列、整数、変数、定数、関数定義、メソッド呼び出しなどがあります。
NodePattern
RubocopにはNodePatternというASTに対する正規表現のようなものがあります。NodePatternはASTにマッチする文字列です。
次のコードはcode
をASTに変換し、NodePatternにマッチする場合はtrue
、 マッチしない場合はnil
が返却されるコードです。
'1'
はASTでは(int 1)
で表現されるため、1つ目のint
にはマッチしますが、2つ目のstr
(文字列)にはマッチしないためnil
になります。
require 'rubocop'
code = '1'
source = RuboCop::ProcessedSource.new(code, RUBY_VERSION.to_f)
node = source.ast
RuboCop::AST::NodePattern.new('int').match(node) # => true
RuboCop::AST::NodePattern.new('str').match(node) # => nil
括弧で囲われたパターンはノードの種類と値の文字列表現に一致するときtrue
を返します。
RuboCop::AST::NodePattern.new('(int 1)').match(node) # => true
RuboCop::AST::NodePattern.new('(int 2)').match(node) # => nil
内部ノードが何であるかを気にしない場合は、...
を使って省略し、任意要素とマッチすることができます。
RuboCop::AST::NodePattern.new('(int ...)').match(node) # => true
RuboCop::AST::NodePattern.new('(str ...)').match(node) # => nil
$...
を使うことでマッチしたコードの一部を取り出す事ができます。
1つ目の例は、レシーバ、メソッド名を取得します。2つ目の例はメソッド名だけ取得します。3つ目の例はレシーバだけを取得します。小文字のs
は内部表現でASTノードを表しています。
code = 'something.empty?'
source = RuboCop::ProcessedSource.new(code, RUBY_VERSION.to_f)
node = source.ast
RuboCop::AST::NodePattern.new('(send $...)').match(node) # => [s(:send, nil, :something), :empty?]
RuboCop::AST::NodePattern.new('(send (...) $...)').match(node) # => [:empty?]
RuboCop::AST::NodePattern.new('(send $... :empty?)').match(node) # => [s(:send, nil, :something)]
ノードには他に便利なメソッドもあります。
node.type # => :send
node.children # => [s(:send, nil, :something), :empty?]
node.source # => "something.empty?"
CustomCopの導入
RuboCopが導入されているRailsプロジェクトにCustomCopを導入していきます。
今回は日時(datetime
)を定義しているカラム名の末尾に_at
を付加していない場合に指摘するCopを作ってみます。
CustomCopの雛形
まずはCustomCopの雛形をRuboCop公式からコピーします。lib
配下に/custom_cops/datetime_column_name.rb
を作成します。
module RuboCop::Cop::Style
class SimplifyNotEmptyWithAny < RuboCop::Cop::Base
MSG = 'Use `.any?` and remove the negation part.'.freeze
def_node_matcher :not_empty_call?, <<~PATTERN
(send (send $(...) :empty?) :!)
PATTERN
RESTRICT_ON_SEND = [:!].freeze # optimization: don't call `on_send` unless
# the method name is in this list
def on_send(node)
return unless not_empty_call?(node)
add_offense(node)
end
end
end
雛形のポイント
- RuboCop::Cop::Baseを継承したクラスを作る
- エラーメッセージの定数MSGを定義する
- RESTRICT_ON_SENDで許容できるメソッド名を制限することで最適化する
- def_node_matcherに第一引数をメソッド名、第二引数にNodePatternを定義する
- on_xxxメソッドを実装する
エラーメッセージの定数MSGを定義する
エラーメッセージを動的に変えたい場合はmessage
オプションで変える事もできます。
add_offense(node, message: "エラーメッセージ")
RESTRICT_ON_SEND
で許容できるメソッド名を制限することで最適化する
最適化していない場合、すべてのメソッドに対してon_send
を呼び出してしまうために実行時間が増えてしまいます。この例では最も外側にあるメソッド!
を発見したときだけon_send
が呼び出されます。
on_xxx
メソッドを実装する
on_xxx
メソッドはparser
とrubocop-ast
が提供するASTノードフックです。send型で始まる場合は、on_send
メソッドを実装する必要があります。
解説
RuboCopは`on_xxx`メソッドでフックされ、対象のノードを`not_empty_call?`に定義してあるNodePatternと検証し、マッチした場合はエラーを返す`add_offense`が呼び出され、`MSG`で設定したエラーメッセージが表示されます。
このサンプルコードは`!array.empty?`を禁止するコードになっています。禁止する理由は、より短いコード`array.any?`があるからです。
NodePatternの確認
まずは日時を定義するNodePatternを確認します。migration
でもschema
でもt.datetime カラム名, オプション
となるのでこのパターンのNodePatternを確認します。
$ ruby-parse -e "t.datetime 'column_name', null: false"
(send
(send nil :t) :datetime
(str "column_name")
(kwargs
(pair
(sym :null)
(false))))
これを元に修正していきます。
CustomCopの修正
まずはクラス名をわかりやすいようにDatetimeColumnName
に変更します。
次にRESTRICT_ON_SEND
をdatetime
に絞り最適化します。これでdatetime
メソッドが呼び出されている場合のみに検証を行います。
RESTRICT_ON_SEND = [:datetime].freeze
次にdef_node_matcher
を修正します。
メソッド名をわかりやすいようにdatetime_column_name?
に変更しています。
(send nil :t)
と(kwargs
以降は関係ないので...
で省略しています。:datetime
もRESTRICT_ON_SEND
で絞っているので省略しても問題ないと思いますが、今回は明示的に残しておきます。
今回はNodePatternにマッチするかだけではなく、マッチしたカラム名の末尾を検証したいので"column_name"
にあたる部分を抽出するために$...
に変更します。
def_node_matcher :datetime_column_name?, <<~PATTERN
(send
(...) :datetime
($...)
...)
PATTERN
次にon_xxx
メソッドを修正します。send型なのでon_send
のままにしています。
上記で修正したdatetime_column_name?
で抽出されるカラム名を変数に代入しています。そしてそのカラム名の末尾に_at
がついているか検証をするcolumn_name_end_with_at?
を呼び出して検証します。
datetime_column_name?
で抽出されるカラム名は配列で帰ってくるのでfirst
で取り出します。最初はto_s
で変換していましたが、配列の中身のカラム名がmigrationの場合はハッシュ形式、schemaの場合は文字列形式で帰ってくるのでどちらにも対応できるようにfirst
で抽出しています。
それとdatetime_column_name?
でノードが上手く抽出できなかった場合にnil
が帰ってくるので、first
でエラーになり、NodePatternが間違っている場合に止めることができるのでこのような形にしました。
その後にslice(-3, 3)
でカラム名の最後の3文字を抽出し検証します。
def on_send(node)
column_name = datetime_column_name?(node)
return if column_name_end_with_at?(column_name)
add_offense(node)
end
private
def column_name_end_with_at?(column_name)
column_name.first.slice(-3, 3) == '_at'
end
最終的なものはこちらです。エラーメッセージもわかりやすいように修正してあります。
module RuboCop::Cop::Style
class DatetimeColumnName < RuboCop::Cop::Base
MSG = 'datetime type, add '_at' to the end of the column name.'.freeze
RESTRICT_ON_SEND = [:datetime].freeze
def_node_matcher :datetime_column_name?, '(send (...) :datetime ($...) ...)'
def on_send(node)
column_name = datetime_column_name?(node)
return if column_name_end_with_at?(column_name)
add_offense(node)
end
private
def column_name_end_with_at?(column_name)
column_name.first.slice(-3, 3) == '_at'
end
end
end
CustomCopの実行
作成したCustomCopをRubocopで使えるように設定します。
require:
- './lib/custom_cops/datetime_column_name'
テスト検証用のファイルを作成します。migration
とschema
を検証します。
class CreateTests < ActiveRecord::Migration[6.1]
def change
create_table :tests do |t|
t.string :name
t.datetime :ok_column_name_at
t.datetime :ng_column_name
end
end
end
ActiveRecord::Schema.define(version: 2021_11_11_044715) do
create_table 'tests', charset: 'utf8mb4', collation: 'utf8mb4_bin', force: :cascade do |t|
t.string 'name'
t.datetime 'ok_column_name_at'
t.datetime 'ng_column_name'
end
end
--only
オプションで今回作成したCopのみを有効にして実行します。
$ rubocop --only RuboCop::Cop::Style/DatetimeColumnName test.rb
Offenses:
test.rb:6:7: C: Style/DatetimeColumnName: datetime type, add '_at' to the end of the column name.
t.datetime :ng_column_name
^^^^^^^^^^^^^^^^^^^^^^^^^^
test.rb:15:5: C: Style/DatetimeColumnName: datetime type, add '_at' to the end of the column name.
t.datetime 'ng_column_name'
^^^^^^^^^^^^^^^^^^^^^^^^^^^
1 file inspected, 2 offenses detected
違反箇所を検出できました!
まとめ
ASTの扱いに最初は苦労しましたが、地道に入出力を確認しながら命名規則をRuboCopに取り入れられたので良かったです。これからはauto-correct
対応や、テストも書いていきたいです。
もしも便利なCopを実装できたら、RuboCopにプルリクを投げたり、gemにしたいなと考えています。
Rubyのお仕事に関するご相談
Bageleeの運営会社、palanではRubyに関するお仕事のご相談を無料で承っております。
zoomなどのオンラインミーティング、お電話、貴社への訪問、いずれも可能です。
ぜひお気軽にご相談ください。
この記事は
参考になりましたか?
0
0
関連記事
2021年12月22日
RDBでセットメニューを表現する方法
2021年12月11日
Railsでツリー構造アプリを作ってみた
2021年12月9日
モデルに書いていたメソッドをPOLOに切り出してみた!
2021年12月7日
gem cancancanを使ってみた!
2021年11月15日
CustomCopで命名規則を作ってみた
2021年10月29日
clambyを利用したウイルススキャン
簡単に自分で作れるWebAR
「palanAR」はオンラインで簡単に作れるWebAR作成ツールです。WebARとはアプリを使用せずに、Webサイト上でARを体験できる新しい技術です。
palanARへpalanでは一緒に働く仲間を募集しています
正社員や業務委託、アルバイトやインターンなど雇用形態にこだわらず、
ベテランの方から業界未経験の方まで様々なかたのお力をお借りしたいと考えております。
運営メンバー
Eishi Saito 総務
SIerやスタートアップ、フリーランスを経て2016年11月にpalan(旧eishis)を設立。 マーケター・ディレクター・エンジニアなど何でも屋。 COBOLからReactまで色んなことやります。
sasakki デザイナー
アメリカの大学を卒業後、日本、シンガポールでデザイナーとして活動。
やまかわたかし デザイナー
フロントエンドデザイナー。デザインからHTML / CSS、JSの実装を担当しています。最近はReactやReact Nativeをよく触っています。
Sayaka Osanai デザイナー
Sketchだいすきプロダクトデザイナー。シンプルだけどちょっとかわいいデザインが得意。 好きな食べものは生ハムとお寿司とカレーです。
はらた エンジニア
サーバーサイドエンジニア Ruby on Railsを使った開発を行なっています
こぼり ともろう エンジニア
サーバーサイドエンジニア。SIerを経て2019年7月に入社。日々学習しながらRuby on Railsを使った開発を行っています。
ささい エンジニア
フロントエンドエンジニア WebGLとReactが強みと言えるように頑張ってます。
Damien
WebAR/VRの企画・開発をやっています。森に住んでいます。
ゲスト bagelee
かっきー
まりな
suzuki
miyagi
ogawa
雑食デザイナー。UI/UXデザインやコーディング、時々フロントエンドやってます。最近はARも。
いわもと
デザイナーをしています。 好きな食べ物はラーメンです。
taishi kobari
フロントエンドの開発を主に担当してます。Blitz.js好きです。
kubota shogo
サーバーサイドエンジニア。Ruby on Railsを使った開発を行いつつ月500kmほど走っています!
nishi tomoya
aihara
グラフィックデザイナーから、フロントエンドエンジニアになりました。最近はWebAR/VRの開発や、Blender、Unityを触っています。モノづくりとワンコが好きです。
nagao
SIerを経てアプリのエンジニアに。xR業界に興味があり、unityを使って開発をしたりしています。
Kainuma
サーバーサイドエンジニア Ruby on Railsを使った開発を行なっています
sugimoto
asama
ando
iwasawa ayane
yoko oshimo
異業界からやってきたデザイナー。palanARのUIをメインに担当してます。これからたくさん吸収していきます!