gemをrbs対応してsteepで型チェックできるようにする

5 min read読了の目安(約4900字

先日 ruby 3.0.0 が公開されました。
いろんな機能が追加されていますが、その中でも静的型解析は個人的にも使ってみたかったので、試してみました。
今回は、

  • 自分が作っている型情報の無いgemのコードにTypeProfで型情報を推論してrbsファイルを作成し、gemに添付する。
  • そのgemを使う側のコードで添付されているrbsファイルを元にsteepで型チェックしてもらう。

の2つを試してみたいと思います。

用語

結構新しい単語があって混乱しがちです。

下記のサイトがわかりやすかったです。

https://techlife.cookpad.com/entry/2020/12/09/120454#:~:text=Steep は、Ruby の静,も実装されています。

先程のサイトからの引用ですが、

用語 意味
RBS Rubyの型情報記述用の言語
TypeProf 型情報の無いrubyコードからRBSを出力するツール
steep rbsファイルを用いてrubyに静的型付を導入できるツール

です。

準備

今回は僕が作っている、最近slackに追加されたアルファベットの絵文字を簡単に入力するための
slack_alphabetter
という誰得なgemを使おうと思います。

gemの紹介

基本的にはcliツールで

$ slack_alphabetter 'hello!'
:alphabet-white-h::alphabet-white-e::alphabet-white-l::alphabet-white-l::alphabet-white-o::alphabet-white-exclamation:

と出力されるので、これをコピー(slack_alphabetter 'hello!' | pbcopy など)してslackに貼り付けると

と変換できて楽しい、というgemです。

これは大まかには内部的には、

require 'slack_alphabetter'
puts SlackAlphabetter.convert(引数の文字列)

という感じで動いています。
現状は何も型情報がないので、任意の型のオブジェクトを渡すことができますので、下記のようなエラーを起こせます。

[2] pry(main)> SlackAlphabetter.convert(123)
NoMethodError: undefined method `downcase' for 123:Integer
from /Users/pocari/.rbenv/versions/3.0.0/lib/ruby/gems/3.0.0/gems/slack_alphabetter-0.1.4/lib/slack_alphabetter.rb:24:in `convert'

こういうエラーを回避できるようにslack_alphabetterに型情報を追加してsteepで型チェックすることで、こういった誤った引数の渡している部分をチェックできるようにしよう!というのが今回試す内容です。

gem側の対応

slack_alphabetter側で、typeprofを使い、型情報の無いrubyのコードの型情報を推論してもらいます。

% typeprof lib/slack_alphabetter.rb
# Classes
class OptionParser
  alias set_banner banner=
  alias set_program_name program_name=
  alias set_summary_width summary_width=
  alias set_summary_indent summary_indent=
  alias to_s help
  alias def_option define
  alias def_head_option define_head
  alias def_tail_option define_tail

  class ParseError < RuntimeError
    alias to_s message
  end
end

module SlackAlphabetter
  def emoji: (untyped char, ?String color) -> String
  def self.emoji: (untyped char, ?String color) -> String
  def convert: (untyped str, ?String color) -> untyped
  def self.convert: (untyped str, ?String color) -> untyped

  class Error < StandardError
  end
end

module Shellwords
  alias shellwords shellsplit
  alias self.shellwords self.shellsplit
  alias self.split self.shellsplit
  alias self.escape self.shellescape
  alias self.join self.shelljoin
end

なにやら SlackAlphabetter.convert の引数が untyped(=なんでもいい型) になっています。また、 slack_alphabetter内でrequireしている optparse の情報も出力されています。
前者のuntypedについては、typeprofの仕組みが実際にrubyのコードを型レベルで 実行し、実際にメソッドに渡されている引数の情報を元に推論しているためで、このファイルのようにメソッド定義だけあっても推論はされません。
後者のoptparseの定義に関しては、ちょっとよくわかりませんが、そういうものなんですかね??

とりあえず、ここでは、SlackAlphabetterの定義だけ使わせてもらって、手動で引数の情報を書くことにします。また、SlackAlphabetter内部のErrorクラスも全然つかってないので、削除します。

ということで、下記のようなrbsファイルを作成します。

slack_alphabetter.rbs
module SlackAlphabetter
  def emoji: (String char, ?String color) -> String
  def self.emoji: (String char, ?String color) -> String
  def convert: (String str, ?String color) -> String
  def self.convert: (String str, ?String color) -> String
end

rbsを置く場所

rbsができましたが、これをgemのどこにおけばいいんでしょう?
調べてみると、steepでチェックする場合(他のツールがどうかはしらないです) gemのインストールディレクトリ/sig のフォルダ以下にあるrbsファイルを読んでもらえるようなので、sig/slack_alphabetter.rbs として保存します。

gemファイルのルート
+ bin/
+ exe/
+ lib/
+ pkg/
- sig/
    slack_alphabetter.rbs
+ spec/
  Gemfile
  Gemfile.lock
  LICENSE.txt
  Rakefile
  README.md
  slack_alphabetter.gemspec

この状態で、gemリリースしたものが、 slack_alphabetterの バージョン 0.1.4 になります。

gemを使う側の対応

次にgemを使う側で、slack_alphabetterとsteepをいれて、型チェックしてみます。
一式は https://github.com/pocari/steep_test にpushしています。

まず、今回の対象のコードです。

lib/main.rb
require 'slack_alphabetter'

puts SlackAlphabetter.convert('hoge')
puts SlackAlphabetter.convert(123)

1つ目のconvert呼び出しはStringを渡しているので正常、2つ目はIntegerを渡しているので型エラーになってほしい呼び出し方です。

次にsteepの設定です。
steepは実行のためにSteepfileというものが必要でそこで使うライブラリの設定などをしていきます。

Steepfile
target :lib do
  check "lib"
  library "slack_alphabetter"
end

check でチェック対象rubyコードがあるディレクトリを指定し、library で型チェックの対象にしたいライブラリを指定します。これをすることで、先ほどgem側で追加したsigディレクトリの中のrbsファイルを使って型チェックをしてくれるそうです。

これで準備できたので、steepで型チェックしてみましょう。

$ bundle exec steep check
lib/main.rb:4:30: ArgumentTypeMismatch: receiver=singleton(::SlackAlphabetter), expected=::String, actual=::Integer (123)

無事、エラーになってほしい Integerを渡している行のみArgumentTypeMismatchになってくれました。

まとめ

  • gemに型情報を添付してみた。
  • そのgemを使う側のコードを型チェックしてみた。

その他、steepはlspも実装しているらしく、念願のエディタでのrubyコードの自動補完などもできるようになるとますます書きやすくなりそうで楽しみですね。