RBSの練習としてhatenablogというgemの型定義をRBSで書いた。
https://github.com/kymmt90/hatenablog/blob/v0.8.0/sig/hatenablog.rbs
まだ該当gemのsigディレクトリに置いているだけだが、やったことを書いておく。
作業の流れ
Ruby 3.0をインストールするなどしてrbs
、typeprof
は使える状態になっているとする。
- TypeProfで型定義ファイルの雛形を生成する
- Steepを設定する
rbs collection
でサードパーティgemの型定義を導入する
steep check
を実行してエラーを確認する- 型定義やコード本体を修正し、エラーを解消する
- CIでSteepを実行する
ディレクトリ構造
次のようなディレクトリ構造とした。
. ├── Steepfile ├── lib │ └── (gemのコード) ├── rbs_collection.lock.yaml ├── rbs_collection.yaml └── sig └── hatenablog.rbs
- sigディレクトリにRBSで書いた型定義を置く
- ルートディレクトリに型チェックを実行するSteepの設定ファイルであるSteepfileを置く
- ルートディレクトリにサードパーティgemを管理する
rbs collection
コマンドで使うrbs_collection.yamlとrbs_collection.lock.yamlを置く
型定義ファイルの雛形を作る
今回はTypeProfで雛形となるRBSの型定義ファイルを生成した(ce61db1)。
$ typeprof lib/**/*.rb test/hatenablog/*.rb -I lib/hatenablog > sig/hatenablog.rbs
テストもlib配下のコードと一緒にTypeProfに入力して実際にメソッドが使われているコードを増やしたところ、libだけを入力したときよりもuntyped
が減った。この方法を使う場合、テスト内のクラスが型定義に入るので削除しておく。
サードパーティgemの型定義を導入する
rbs collection
を使うとサードパーティgemの型定義が管理できる。今回はnokogiriのためにこの機能を使う。
rbs/collection.md at v1.7.1 · ruby/rbs
rbs collection
を使うにはrbs_collection.yamlを作成する。rbs collection init
で同ファイルを生成したあと、必要なgemが入るように設定を書く。今回はrbs_railsの同ファイルを参考にして次のように書いた。
# Download sources sources: - name: ruby/gem_rbs_collection remote: https://github.com/ruby/gem_rbs_collection.git revision: main repo_dir: gems # A directory to install the downloaded RBSs path: .gem_rbs_collection gems: # stdlibs - name: erb - name: net-http - name: set - name: time - name: uri # gems - name: nokogiri # not used - name: activesupport ignore: true - name: ast ignore: true - name: listen ignore: true - name: parallel ignore: true - name: rainbow ignore: true - name: rbs ignore: true - name: steep ignore: true
設定の意図は次のとおり。
- 型定義のインストール先を
path
で設定する。ドキュメント通りにプロジェクトのルートディレクトリの.gem_rbs_collectionとする - 必要なgemのリストを
gems
配下のリストに追加する - 自動生成するとRBSとSteep(とその依存)自体の型定義もリストに入るが、これらをライブラリとして使っているわけではないので
ignore: true
で無視する
このファイルが存在する状態でrbs collection install
すると、bundle install
のように、ruby/gem_rbs_collectionから必要な型定義を取得して.gem_rbs_collectionに保存する。このとき、使用する型定義のバージョンを記録するためのrbs_collection.lock.yamlが生成される。
--- sources: - name: ruby/gem_rbs_collection remote: https://github.com/ruby/gem_rbs_collection.git revision: main repo_dir: gems path: ".gem_rbs_collection" gems: - name: erb version: '0' source: type: stdlib - name: net-http version: '0' source: type: stdlib - name: set version: '0' source: type: stdlib - name: time version: '0' source: type: stdlib - name: uri version: '0' source: type: stdlib - name: nokogiri version: '1.11' source: type: git name: ruby/gem_rbs_collection revision: 88e86e0b67262f9ab6244a356e81dd9ca8c55b37 remote: https://github.com/ruby/gem_rbs_collection.git repo_dir: gems
今回はこのファイルもrbs_railsにならってリポジトリにコミットしたが、gemでGemfile.lockはコミットすべきでないという話と同様に、rbs_collection.lock.yamlもbin/setupとかで都度生成するようにしたほうがいいのかもしれない。
Steepを設定する
RBSによる型定義にしたがったコードになっているかはSteepでチェックする。BundlerでSteepをインストールする。
# Gemfile group :development, :test do gem 'steep' end
設定をSteepfileに書く。steep init
で生成した雛形をもとにする。repo_path
で他のgemの型定義が入っているディレクトリのパスを指定し、library
でruby/rbsに入っている標準ライブラリの型定義やサードパーティgemの型定義のうち、使いたいものの名前を指定することで、他のgemの型定義が利用できる。今回はconfigure_code_diagnostics
を使い、最も強いエラーレベルでlib配下のコードをチェックするように設定した。
# Steepfile D = Steep::Diagnostic target :lib do check "lib" signature "sig" repo_path ".gem_rbs_collection" library "erb", "net-http", "nokogiri", "set", "time", "uri" configure_code_diagnostics(D::Ruby.all_error) end
型定義ファイルやコードを修正する
ここまでで型チェックの準備ができた。TypeProfで生成した型定義で
$ bundle exec steep check
を実行するとチェックがたくさん失敗するので、チェックがすべて通るまで型定義や場合によってはコードを直していく。該当PRだと3つ目のコミット以降。
ここでは、一般的に発生しそうなエラーとその解消方法をいくつか取り上げる。
サードパーティgemの型定義に起因するエラー
このgemではXMLを扱うためにNokogiriを使っていて、次のようなコードが存在する。
@categories.each do |category| prev_node.next = @document.create_element('category', term: category) prev_node = prev_node.next end
@document
がNokogiri::XML::Document
のインスタンスであり、create_element
はそのメソッド。このメソッドは省略可能なブロックを取ることができるのだが、gem_rbs_collectionでは次のように省略不可になっていて、結果としてSteepのチェックがエラーになった。
def create_element: (untyped name, *untyped args) { (*untyped) -> untyped } -> untyped
サードパーティgem側の型定義を修正すべき場合は修正する。今回はruby/gem_rbs_collectionのNokogiriの該当シグネチャを修正した。
Nokogiri: Make block arguments optional by kymmt90 · Pull Request #88 · ruby/gem_rbs_collection
サードパーティgemの型定義が存在しないエラー
このgemが依存するgemのうち、ostruct(OpenStruct
を提供する)とoauthとyamlの型定義は、2021年11月時点ではruby/rbsやruby/gem_rbs_collectionに存在しない。これらの型定義については、ひとまずSteepのチェックが通るようにpolyfillを追加した。
# polyfill for ostruct class OpenStruct def initialize: (?Hash[untyped, untyped]? hash) -> OpenStruct def []: (String | Symbol) -> Object def to_h: -> Hash[Symbol, Object] end # polyfill for oauth module OAuth class AccessToken def initialize: (untyped, untyped, ?untyped) -> void end class Consumer def initialize: (untyped, untyped, ?untyped) -> void end end # polyfill for yaml module YAML def self.load: (String yaml, ?String? filename, ?fallback: bool, ?symbolize_names: bool) -> untyped end
これらについてもruby/rbs、ruby/gem_rbs_collectionにコントリビュートするのが望ましい。
"Type (Foo | nil)
does not have method bar
"のエラー
次のようにnilになりうるクラスFoo
の変数
@foo: Foo?
に対してメソッドbar
を呼び出していると、NilClassにそのようなメソッドがないので表題のエラーになる。いかにnilになるかどうかを意識せずにコードを書いているかがわかる。
現在は、状況に応じていくつかの方法で対応している。
nilにならない型に変更する
このエラーが起きている変数や引数の型からoptionalの?
を外すことに問題がない場合、外す。すると(Foo | nil)
という型からFoo
という型になるので、このエラーは起きなくなる。
&.
を使う
本当にnilになる可能性もある変数なのであれば、&.
を使う。&.
であればnilに対してもメソッドを呼び出せるので、Steepのエラーは発生しない。
たとえば
> lib/hatenablog/entry.rb:167:61: [error] Type `(::Time | nil)` does not have method `iso8601` > │ Diagnostic ID: Ruby::NoMethod > │ > └ @document.at_css('entry updated').content = @updated.iso8601 ~~~~~~~
というエラーに対して
@document.at_css('entry updated').content = @updated&.iso8601
というコードに変える。
"Type (^(untyped) -> untyped | nil)
does not have method call
"のエラー
先ほどのエラーと似ているが、ここではブロック引数がエラーの対象。
たとえばEnumerator#eachの型は2021年11月現在次のようになっている。
def each: () { (Elem arg0) -> untyped } -> Return | () -> self
ところで、Enumerable
をincludeしてeach
を実装しているクラスの場合、
- ブロックが渡されないときはEnumeratorのインスタンスを返す
- ブロックが渡されるときはブロックを実行する
という挙動を実現するために次のようなコードを書くことがある。
def each(&block) return enum_for unless block_given? @categories.each do |category| block.call(category) end end
このコードではblock_given?
を使ってガードすることで、ブロックがないときEnumeratorをすぐに返している。また、このメソッドの型定義は、次のようにブロックを取らずにEnumeratorを返すか、もしくはブロックを取ることを表現するものになる。
def each: () -> Enumerator[untyped, self] | () { (String) -> void } -> Array[String]
これでうまくいきそうだが、コード上は&block
はnilになりうる変数なので、Steepは引数&block
の型を^(untyped) -> untyped | nil
と見なす。よって、そのままcall
しようとすると、nilの可能性が考慮されて表題のエラーになる。
この問題は、Steepの機能であるアノテーションをコードに追加し、Steepにblock
の型を明示することで解決できる。
def each(&block) return enum_for unless block_given? @categories.each do |category| # @type var block: ^(String) -> void block.call(category) end end
@type
から始まるコメントが変数に対するアノテーション。block
の型が^(untyped) -> untyped | nil
ではなく^(String) -> void
であることを明示している。Steepはこのコメントを見て型チェックすることで表題のエラーを発生させなくなる。
動的に生成するメソッドに関するエラー
attr_accessorやOpenStructを使っていると動的にgetter/setterメソッドが生成される。これらのメソッド定義はコード中に存在しないので、そのままsteep check
すると次のエラーが発生する。
lib/hatenablog/feed.rb:7:8: [error] Cannot find implementation of method `::Hatenablog::Feed#uri` │ Diagnostic ID: Ruby::MethodDefinitionMissing │ └ class Feed ~~~~
動的に生成するメソッドについては、@dynamic
というSteepのアノテーションをコードに追加することで、Steepがメソッド定義を見つけられなくてもエラーにしなくなる。
class Feed # @dynamic uri, next_uri, title, author_name, updated attr_reader :uri, :next_uri, :title, :author_name, :updated end
モジュールのメソッドでModule
のインスタンスメソッドを使うとエラー
このgemではモジュールのメソッドでinstance_methods
したりalias_method
したりdefine_method
したりとModule
のインスタンスメソッドを使っている。このモジュールは別のクラスでextendするという用途のために存在している。
あるモジュールはModule
クラスのインスタンスなので、モジュールのメソッドでinstance_methods
などのメソッドをレシーバなしで呼び出すときSteepのチェックが通ることを期待していたが、次のエラーになった(ここでは::Hatenablog::AfterHook
が該当のモジュール)。
> lib/hatenablog/entry.rb:13:11: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `instance_methods` > │ Diagnostic ID: Ruby::NoMethod > │ > └ if instance_methods.include? origin_method > ~~~~~~~~~~~~~~~~ > > lib/hatenablog/entry.rb:17:8: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `alias_method` > │ Diagnostic ID: Ruby::NoMethod > │ > └ alias_method origin_method, method > ~~~~~~~~~~~~ > > lib/hatenablog/entry.rb:19:8: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `define_method` > │ Diagnostic ID: Ruby::NoMethod > │ > └ define_method(method) do |*args, &block| > ~~~~~~~~~~~~~
実際はそれぞれちゃんと型定義が存在するが、"does not have method"と言われている。
とりあえず次のように型定義を素朴に追加するとチェックが通るのでそうしているが、もっといい方法がありそう。
module AfterHook # ... def alias_method: (::Symbol | ::String new_name, ::Symbol | ::String old_name) -> ::Symbol def define_method: (Symbol | String arg0, ?Proc | Method | UnboundMethod arg1) -> Symbol | (Symbol | String arg0) { () -> untyped } -> Symbol def instance_methods: (?boolish include_super) -> ::Array[Symbol] end
CIでSteepを実行する
GitHub ActionsでSteepも実行するようにしておく。ワークフローに次のようにジョブを定義すればよい。
jobs: steep: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - run: bundle install -j4 - run: rbs collection install - run: bundle exec steep check
steep checkの成功
Steepのチェックがすべて通ると標準出力に次のようなメッセージが表示される。これでgemの中のロジックについては型定義と矛盾しない状態になっていることが確認できた。
$ bundle exec steep check # Type checking files: ................................................................................... No type error detected. 🧉