こんにちは。RuboCop大好きpockeです! SideCIでは対応ツールの追加など、主にサーバーサイドの開発を担当しています。
今回は、SideCIでも使用しているRubyの静的解析ツール、RuboCop のプラグインの作り方について書こうと思います。
実際にRuboCopプラグインを作りながら手順を解説します!
はじめに
今回は、よくあるバグを検出するCopを作ってみましょう。
以下のコードにはバグがありますが、一見しただけだとその存在に気が付かないかも知れません。
def foo raise StandardError "error!" end
本来このコードには、StandardError
と"error!"
の間にカンマが必要です。
カンマが抜けていると「StandardError
というメソッドを"error!"
という引数で呼び出す」という意味になってしまい、想定しない挙動になってしまいます。
次章から、このようなコードを指摘してくれるRuboCopのプラグインを作成していきましょう。
レポジトリの初期化
まずは通常のgemを作る時と同様に、bundler
を使用してレポジトリを初期化します。
Copの名前はexception_call
としました。
$ bundle gem rubocop-exception_call Creating gem 'rubocop-exception_call'... create rubocop-exception_call/Gemfile create rubocop-exception_call/.gitignore create rubocop-exception_call/lib/rubocop/exception_call.rb create rubocop-exception_call/lib/rubocop/exception_call/version.rb create rubocop-exception_call/rubocop-exception_call.gemspec create rubocop-exception_call/Rakefile create rubocop-exception_call/README.md create rubocop-exception_call/bin/console create rubocop-exception_call/bin/setup Initializing git repo in /home/pocke/ghq/github.com/pocke/rubocop-exception_call $ cd rubocop-exception_call/
ここで一旦コミットします。
initalize gem · actcat/rubocop-exception_call@067d4d0
gemspec の修正
rubocop-exception_call.gemspec
を以下のように修正します。
# coding: utf-8 lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'rubocop/exception_call/version' Gem::Specification.new do |spec| spec.name = "rubocop-exception_call" spec.version = Rubocop::ExceptionCall::VERSION spec.authors = ["Masataka Kuwabara"] spec.email = ["p.ck.t22@gmail.com"] spec.summary = %q{例外送出の際のカンマ忘れを指摘するRubocopプラグイン} spec.description = %q{例外送出の際のカンマ忘れを指摘するRubocopプラグイン} spec.homepage = "https://github.com/actcat/rubocop-exception_call" spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_runtime_dependency "rubocop", "~> 0.40.0" spec.add_development_dependency "bundler", "~> 1.11" spec.add_development_dependency "rake", "~> 10.0" end
ポイントは、add_runtime_dependency
でrubocop
を指定した点と、summary
などを記述したことです。
summary
の記述を変更しないとgemをインストールして試すことが出来ないため、この段階で何かしら説明文を追加しましょう。
後ほどテストで使うgemもインストールしてあります。
また、依存ライブラリのインストールが必要なので、bundle install
を走らせましょう。
Fix gemspec · actcat/rubocop-exception_call@77c38df
基本的なファイルの編集
次に、いくつかのファイルを追加します。
config/default.yml
lib/rubocop/exception_call/inject.rb
lib/rubocop/cop/lint/exception_call.rb
lib/rubocop/exception_call.rb
上から順に解説します。
config/default.yml
は、作成したCopのデフォルト設定を書きます。
今回は特別な設定は特にないので、書くことは「Copの説明」と「デフォルトで有効にするか」の2つのみです。
Lint/ExceptionCall: Description: '例外送出の際のカンマ忘れを指摘します' Enabled: true
inject.rb
では、先ほど作成した設定ファイルを読み込みます。
こちらのファイルはrubocop-rspecからのコピペになります。
https://github.com/nevir/rubocop-rspec/blob/master/lib/rubocop/rspec/inject.rb
# This file is copied from rubocop-rspec require 'yaml' module RuboCop module ExceptionCall # Because RuboCop doesn't yet support plugins, we have to monkey patch in a # bit of our configuration. module Inject DEFAULT_FILE = File.expand_path( '../../../../config/default.yml', __FILE__ ) def self.defaults! path = File.absolute_path(DEFAULT_FILE) hash = ConfigLoader.send(:load_yaml_configuration, path) config = Config.new(hash, path) puts "configuration from #{DEFAULT_FILE}" if ConfigLoader.debug? config = ConfigLoader.merge_with_default(config, path) ConfigLoader.instance_variable_set(:@default_configuration, config) end end end end
lib/rubocop/cop/lint/exception_call.rb
には、次章以降でCopの処理本体を書いていきます。
とりあえず空のファイルを作っておきましょう。
lib/rubocop/exception_call.rb
では、このCopで使用するファイルを全て読み込みます。
また、先ほど作成したinject.rb
の処理を呼び出す必要があります。
require 'rubocop' require "rubocop/exception_call/version" require 'rubocop/exception_call/inject' RuboCop::ExceptionCall::Inject.defaults! # cops require 'rubocop/cop/lint/exception_call'
Add some files. · actcat/rubocop-exception_call@8715375
Cop の作成
いよいよ本題のCopの作成部分です。
# encoding: utf-8 # frozen_string_literal: true module RuboCop module Cop module Lint class ExceptionCall < Cop MSG = 'カンマ(,)を忘れていませんか?'.freeze def on_send(node) receiver, method_name, *args = *node return unless [:raise, :fail].include?(method_name) return unless check_receiver(receiver) return unless check_args(args) add_offense(node, loc(args)) end def autocorrect(node) _receiver, _method_name, *args = *node lambda do |corrector| corrector.insert_before(loc(args), ',') end end private def check_receiver(receiver) return true unless receiver return true if receiver.const_type? && receiver.const_name == 'Kernel' return false end def check_args(args) return false unless args.size == 1 arg = args.first return false unless arg.send_type? _receiver, method_name, _args = *arg return method_name =~ /^[A-Z]/ end def loc(args) arg = args.first _receiver, _method_name, inner_args = *arg end_pos = inner_args.loc.begin.begin_pos begin_pos = arg.loc.selector.end_pos Parser::Source::Range.new(arg.loc.expression.source_buffer, begin_pos, end_pos) end end end end end
まずは、RuboCopの問題検知のシステムについて説明します。
RuboCopはイベント駆動で動作しています。
Rubyのコードを解析し、メソッド呼び出しがあればon_send
、クラス定義があればon_class
、メソッド定義があればon_def
のように、各イベントごとにCop側で定義したメソッドが呼ばれる用になっています。
先ほどのコードのon_send
はメソッド呼び出しのイベントが発生した時に呼ばれるメソッドです。
では、on_send
メソッドの中を見ていきましょう。
on_send
メソッドでは、以下の3つの項目についてチェックを行い、全てを満たす場合に問題点として指摘を追加しています。
- メソッド名が
raise
かfail
であるか - メソッドのレシーバーが
Kernel
もしくは明示的にしていされていないか - 引数が大文字から始まるメソッドを呼び出す形になっているか
上記の様なチェックは、主にxxx_type?
というメソッドを使用して行います。
例えば、例外クラスがメソッド呼び出しとして扱われているかをチェックするには.send_type?
メソッドを使用します。
そして問題が検出された場合、add_offense
メソッドを使用して対象のトークンに問題があることを通知します。
この際表示されるメッセージとして、クラス内のMSG
定数が使用されます。
応用編になりますが、autocorrect
メソッドではRuboCopによるコードの自動修正を行うことが出来ます。
コードの修正を行うlambdaを返すことで、autocorrectを実装することができます。
Add cop code · actcat/rubocop-exception_call@c6485a9
テストの準備
この章では、テストを書くための準備をしていきます。
まず、RuboCop本体に含まれているテスト用のヘルパーメソッドを使用するため、git submodule
としてRuboCopを追加しましょう。
$ git submodule add git@github.com:bbatsov/rubocop.git vendor/rubocop
次に、RSpecの初期化を行います。
$ bundle exec rspec --init
また、ヘルパーメソッドの読み込みを行うため、spec/spec_helper.rb
の先頭に以下のコードを追記します。
require 'rubocop' require 'rubocop/exception_call' rubocop_path = File.join(File.dirname(__FILE__), '../vendor/rubocop') unless File.directory?(rubocop_path) raise "Can't run specs without a local RuboCop checkout. Look in the README." end Dir["#{rubocop_path}/spec/support/**/*.rb"].each { |f| require f }
これでテストを実行する準備が整いました。一旦コミットしましょう。
Initialise RSpec env · actcat/rubocop-exception_call@d1d5e2a
(将来的にはこの操作が不要になる可能性があります。 See Expose files to support testing Cops using RSpec by tjwp · Pull Request #3179 · bbatsov/rubocop)
テストの作成
この章では、テストを書いていきます。
spec/rubocop/cop/lint/exception_call_spec.rb
を編集していきましょう。
# encoding: utf-8 # frozen_string_literal: true require 'spec_helper' describe RuboCop::Cop::Lint::ExceptionCall do subject(:cop) { described_class.new } context 'raise' do let(:source){'raise StandardError "foo"'} it '' do inspect_source(cop, source) expect(cop.messages) .to eq(['カンマ(,)を忘れていませんか?']) expect(cop.offenses.size).to eq(1) expect(cop.highlights).to eq([' ']) end it 'auto-correct comma' do new_source = autocorrect_source(cop, source) expect(new_source) .to eq('raise StandardError, "foo"') end end context 'fail' do let(:source){'fail ArgumentError "bar"'} it '' do inspect_source(cop, source) expect(cop.messages) .to eq(['カンマ(,)を忘れていませんか?']) expect(cop.offenses.size).to eq(1) expect(cop.highlights).to eq([' ']) end it 'auto-correct comma' do new_source = autocorrect_source(cop, source) expect(new_source) .to eq('fail ArgumentError, "bar"') end end context 'Kernel.raise' do let(:source){'Kernel.raise IOError "baz"'} it '' do inspect_source(cop, source) expect(cop.messages) .to eq(['カンマ(,)を忘れていませんか?']) expect(cop.offenses.size).to eq(1) expect(cop.highlights).to eq([' ']) end it 'auto-correct comma' do new_source = autocorrect_source(cop, source) expect(new_source) .to eq('Kernel.raise IOError, "baz"') end end context 'success' do let(:source){'raise StandardError, "foobar"'} it do inspect_source(cop, source) expect(cop.messages) expect(cop.offenses).to be_empty end end end
テストの内容は、3段階に分けられます。
- トップレベルに
cop
というsubjectを定義 it
内でinspect_source
を使用し、コード解析を実行cop.messages
,cop.offences
,cop.highlights
などに実行結果が格納されているので、それをチェック
Write spec · actcat/rubocop-exception_call@257355d
実行
これで、カスタムCopの完成です!
作成したCopをインストール、実行する際には以下のようにします。
$ bundle exec rake install:local $ echo "raise StandardError 'error!'" > test.rb $ rubocop -r rubocop/exception_call test.rb Inspecting 1 file W Offenses: test.rb:1:20: W: カンマ(,)を忘れていませんか? raise StandardError 'error!' ^ 1 file inspected, 1 offense detected
また、作成したCopを公開したい場合は通常のgemと同じようにrake release
でgemとして公開することが可能です。
注意点など
RuboCop拡張を開発する際の注意点等をまとめたいと思います。
--cache false する
開発時にテスト実行する際、rubocopに--cache false
をつけないとキャッシュが有効になってしまい期待した結果が出ないことが多々あります。
そのため、開発時に実行する際は--cache false
を付与してRuboCopを実行するようにしましょう。
多くのコードに対して走らせてみる
Rubyの文法は複雑で、作成したCopが予期しないコードに対して例外をはいてしまうことは良くあります。
そのため、多くのコードに対して作成したCopを実行してみることがとても重要です。
また、例外の多くはsend_type?
などの確認漏れであることが多いため、「今扱っているトークンはどの種類のものなのか」を意識してコードを書き、意識してtypeをチェックするコードを書くようにすると堅牢性がより上がるでしょう。
まとめ
長い記事になってしまいましたが、お読みいただいてありがとうございました。
RuboCop本体のCop定義を読むと、更にカスタムCop作成に対しての知識が深まると思います。
この記事がカスタムCop作成の手助けになれば幸いです!
今回作成したCopはこちらになります。