2021年11月15日

プログラミング

CustomCopで命名規則を作ってみた

目次

  1. はじめに
  2. RuboCopについて
  3. CustomCopの導入
  4. まとめ

はじめに

現在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メソッドはparserrubocop-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_SENDdatetimeに絞り最適化します。これでdatetimeメソッドが呼び出されている場合のみに検証を行います。

RESTRICT_ON_SEND = [:datetime].freeze

次にdef_node_matcherを修正します。
メソッド名をわかりやすいようにdatetime_column_name?に変更しています。
(send nil :t)(kwargs以降は関係ないので...で省略しています。:datetimeRESTRICT_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'

テスト検証用のファイルを作成します。migrationschemaを検証します。


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

AUTHOR

kainuma

Kainuma

サーバーサイドエンジニア Ruby on Railsを使った開発を行なっています

アプリでもっと便利に!気になる記事をチェック!

記事のお気に入り登録やランキングが表示される昨日に対応!毎日の情報収集や調べ物にもっと身近なメディアになりました。

簡単に自分で作れるWebAR

「palanAR」はオンラインで簡単に作れるWebAR作成ツールです。WebARとはアプリを使用せずに、Webサイト上でARを体験できる新しい技術です。

palanARへ
palanar

palanはWebARの開発を
行っています

弊社では企画からサービスの公開終了まで一緒に関わらせていただきます。 企画からシステム開発、3DCG、デザインまで一貫して承ります。

webar_waterpark

palanでは一緒に働く仲間を募集しています

正社員や業務委託、アルバイトやインターンなど雇用形態にこだわらず、
ベテランの方から業界未経験の方まで様々なかたのお力をお借りしたいと考えております。

話を聞いてみたい

運営メンバー

eishis

Eishi Saito 総務

SIerやスタートアップ、フリーランスを経て2016年11月にpalan(旧eishis)を設立。 マーケター・ディレクター・エンジニアなど何でも屋。 COBOLからReactまで色んなことやります。

sasakki デザイナー

アメリカの大学を卒業後、日本、シンガポールでデザイナーとして活動。

yamakawa

やまかわたかし デザイナー

フロントエンドデザイナー。デザインからHTML / CSS、JSの実装を担当しています。最近はReactやReact Nativeをよく触っています。

Sayaka Osanai デザイナー

Sketchだいすきプロダクトデザイナー。シンプルだけどちょっとかわいいデザインが得意。 好きな食べものは生ハムとお寿司とカレーです。

はらた

はらた エンジニア

サーバーサイドエンジニア Ruby on Railsを使った開発を行なっています

kobori

こぼり ともろう エンジニア

サーバーサイドエンジニア。SIerを経て2019年7月に入社。日々学習しながらRuby on Railsを使った開発を行っています。

sasai

ささい エンジニア

フロントエンドエンジニア WebGLとReactが強みと言えるように頑張ってます。

damien

Damien

WebAR/VRを中心に企画・マークアップ・開発をやっています。森に住んでいます。

ゲスト bagelee

ゲスト bagelee

かっきー

かっきー

まりな

まりな

suzuki

suzuki

taro

taro

xR界隈のビズをやっています。新しいガジェットとか使うのが好きです。あとお寿司は玉子のお寿司が好きです。

miyagi

ogawa

ogawa

雑食デザイナー。UI/UXデザインやコーディング、時々フロントエンドやってます。最近はARも。

いわもと

いわもと

kobari

taishi kobari

フロントエンドの開発を主に担当してます。Blitz.js好きです。

shogokubota

kubota shogo

サーバーサイドエンジニア。Ruby on Railsを使った開発を行いつつ月500kmほど走っています!

nishi tomoya

aihara

aihara

グラフィックデザイナーから、フロントエンドエンジニアになりました。最近はWebAR/VRの開発や、Blender、Unityを触っています。モノづくりとワンコが好きです。

nagao

SIerを経てアプリのエンジニアに。xR業界に興味があり、unityを使って開発をしたりしています。

kainuma

Kainuma

サーバーサイドエンジニア Ruby on Railsを使った開発を行なっています

sugimoto

sugimoto

CONTACT PAGE TOP