読者です 読者をやめる 読者になる 読者になる

SideCI TechBlog

SideCIを作っているアクトキャットのエンジニアによる技術ブログです。

実践!! RuboCopプラグイン開発入門

こんにちは。RuboCop大好きpockeです! SideCIでは対応ツールの追加など、主にサーバーサイドの開発を担当しています。

f:id:sideci-dev:20160609111846p:plain

今回は、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_dependencyrubocopを指定した点と、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つの項目についてチェックを行い、全てを満たす場合に問題点として指摘を追加しています。

  • メソッド名が raisefailであるか
  • メソッドのレシーバーが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はこちらになります。

参考