既存のgemにRBSで型定義を書く

RBSの練習としてhatenablogというgemの型定義をRBSで書いた。

https://github.com/kymmt90/hatenablog/blob/v0.8.0/sig/hatenablog.rbs

まだ該当gemのsigディレクトリに置いているだけだが、やったことを書いておく。

作業の流れ

Ruby 3.0をインストールするなどしてrbstypeprofは使える状態になっているとする。

  • 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

@documentNokogiri::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. 🧉

参考にした資料